diff --git a/blocksuite/affine/blocks/image/src/image-edgeless-block.ts b/blocksuite/affine/blocks/image/src/image-edgeless-block.ts index 4dacc45e70..74cdfc0016 100644 --- a/blocksuite/affine/blocks/image/src/image-edgeless-block.ts +++ b/blocksuite/affine/blocks/image/src/image-edgeless-block.ts @@ -26,6 +26,11 @@ import { @Peekable() export class ImageEdgelessBlockComponent extends GfxBlockComponent { + private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024; + private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080; + private static readonly LOD_MAX_ZOOM = 0.4; + private static readonly LOD_THUMBNAIL_MAX_EDGE = 256; + static override styles = css` affine-edgeless-image { position: relative; @@ -63,6 +68,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent= 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((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(null); + } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'low'; + ctx.drawImage(image, 0, 0, targetWidth, targetHeight); + + return new Promise(resolve => { + canvas.toBlob(resolve); + }); + } + + private _ensureLodThumbnail(blobUrl: string) { + if ( + this._lodThumbnailUrl || + this._lodGeneratingSourceUrl === blobUrl || + !this._shouldUseLod(blobUrl) + ) { + return; + } + + const token = ++this._lodGenerationToken; + this._lodGeneratingSourceUrl = blobUrl; + + void this._createImageElement(blobUrl) + .then(image => this._createThumbnailBlob(image)) + .then(blob => { + if (!blob || token !== this._lodGenerationToken || !this.isConnected) { + return; + } + + const thumbnailUrl = URL.createObjectURL(blob); + if (token !== this._lodGenerationToken || !this.isConnected) { + URL.revokeObjectURL(thumbnailUrl); + return; + } + + this._revokeLodThumbnail(); + this._lodThumbnailUrl = thumbnailUrl; + + if (this._shouldUseLod(this.blobUrl)) { + this.requestUpdate(); + } + }) + .catch(err => { + if (token !== this._lodGenerationToken || !this.isConnected) { + return; + } + console.error(err); + }) + .finally(() => { + if (token === this._lodGenerationToken) { + this._lodGeneratingSourceUrl = null; + } + }); + } + + private _updateLodFromViewport(zoom: number) { + const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom); + if (shouldUseLod === this._lastShouldUseLod) { + return; + } + + this._lastShouldUseLod = shouldUseLod; + if (shouldUseLod && this.blobUrl) { + this._ensureLodThumbnail(this.blobUrl); + } + this.requestUpdate(); + } + override connectedCallback() { super.connectedCallback(); @@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent { + this._resetLodSource(null); this.refreshData(); }) ); + + this.disposables.add( + this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => { + this._updateLodFromViewport(zoom); + }) + ); + + this._lastShouldUseLod = this._shouldUseLod(this.blobUrl); + } + + override disconnectedCallback() { + this._lodGenerationToken += 1; + this._lodGeneratingSourceUrl = null; + this._lodSourceUrl = null; + this._revokeLodThumbnail(); + super.disconnectedCallback(); } override renderGfxBlock() { const blobUrl = this.blobUrl; const { rotate = 0, size = 0, caption = 'Image' } = this.model.props; + this._resetLodSource(blobUrl); const containerStyleMap = styleMap({ display: 'flex', @@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent @@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent diff --git a/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/misc.ts b/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/misc.ts index 232ccfb20e..8db172e1cd 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/misc.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/misc.ts @@ -33,7 +33,11 @@ import { ReleaseFromGroupIcon, UnlockIcon, } from '@blocksuite/icons/lit'; -import type { GfxModel } from '@blocksuite/std/gfx'; +import { + batchAddChildren, + batchRemoveChildren, + type GfxModel, +} from '@blocksuite/std/gfx'; import { html } from 'lit'; import { renderAlignmentMenu } from './alignment'; @@ -61,14 +65,13 @@ export const builtinMiscToolbarConfig = { const group = firstModel.group; - // oxlint-disable-next-line unicorn/prefer-dom-node-remove - group.removeChild(firstModel); + batchRemoveChildren(group, [firstModel]); firstModel.index = ctx.gfx.layer.generateIndex(); const parent = group.group; if (parent && parent instanceof GroupElementModel) { - parent.addChild(firstModel); + batchAddChildren(parent, [firstModel]); } }, }, @@ -255,9 +258,12 @@ export const builtinMiscToolbarConfig = { // release other elements from their groups and group with top element otherElements.forEach(element => { - // oxlint-disable-next-line unicorn/prefer-dom-node-remove - element.group?.removeChild(element); - topElement.group?.addChild(element); + if (element.group) { + batchRemoveChildren(element.group, [element]); + } + if (topElement.group) { + batchAddChildren(topElement.group, [element]); + } }); if (otherElements.length === 0) { diff --git a/blocksuite/affine/blocks/surface/src/surface-model.ts b/blocksuite/affine/blocks/surface/src/surface-model.ts index a789debf9a..305604d216 100644 --- a/blocksuite/affine/blocks/surface/src/surface-model.ts +++ b/blocksuite/affine/blocks/surface/src/surface-model.ts @@ -40,10 +40,146 @@ export const SurfaceBlockSchemaExtension = export class SurfaceBlockModel extends BaseSurfaceModel { private readonly _disposables: DisposableGroup = new DisposableGroup(); + private readonly _connectorIdsByEndpoint = new Map>(); + private readonly _connectorIndexDisposables = new DisposableGroup(); + private readonly _connectorEndpoints = new Map< + string, + { sourceId: string | null; targetId: string | null } + >(); + + private _addConnectorEndpoint(endpointId: string, connectorId: string) { + const connectorIds = this._connectorIdsByEndpoint.get(endpointId); + + if (connectorIds) { + connectorIds.add(connectorId); + return; + } + + this._connectorIdsByEndpoint.set(endpointId, new Set([connectorId])); + } + + private _isConnectorModel(model: unknown): model is ConnectorElementModel { + return ( + !!model && + typeof model === 'object' && + 'type' in model && + (model as { type?: string }).type === 'connector' + ); + } + + private _removeConnectorEndpoint(endpointId: string, connectorId: string) { + const connectorIds = this._connectorIdsByEndpoint.get(endpointId); + + if (!connectorIds) { + return; + } + + connectorIds.delete(connectorId); + + if (connectorIds.size === 0) { + this._connectorIdsByEndpoint.delete(endpointId); + } + } + + private _removeConnectorFromIndex(connectorId: string) { + const endpoints = this._connectorEndpoints.get(connectorId); + + if (!endpoints) { + return; + } + + if (endpoints.sourceId) { + this._removeConnectorEndpoint(endpoints.sourceId, connectorId); + } + + if (endpoints.targetId) { + this._removeConnectorEndpoint(endpoints.targetId, connectorId); + } + + this._connectorEndpoints.delete(connectorId); + } + + private _rebuildConnectorIndex() { + this._connectorIdsByEndpoint.clear(); + this._connectorEndpoints.clear(); + + this.getElementsByType('connector').forEach(connector => { + this._setConnectorEndpoints(connector as ConnectorElementModel); + }); + } + + private _setConnectorEndpoints(connector: ConnectorElementModel) { + const sourceId = connector.source?.id ?? null; + const targetId = connector.target?.id ?? null; + const previousEndpoints = this._connectorEndpoints.get(connector.id); + + if ( + previousEndpoints?.sourceId === sourceId && + previousEndpoints?.targetId === targetId + ) { + return; + } + + if (previousEndpoints?.sourceId) { + this._removeConnectorEndpoint(previousEndpoints.sourceId, connector.id); + } + + if (previousEndpoints?.targetId) { + this._removeConnectorEndpoint(previousEndpoints.targetId, connector.id); + } + + if (sourceId) { + this._addConnectorEndpoint(sourceId, connector.id); + } + + if (targetId) { + this._addConnectorEndpoint(targetId, connector.id); + } + + this._connectorEndpoints.set(connector.id, { + sourceId, + targetId, + }); + } override _init() { this._extendElement(elementsCtorMap); super._init(); + this._rebuildConnectorIndex(); + this._connectorIndexDisposables.add( + this.elementAdded.subscribe(({ id }) => { + const model = this.getElementById(id); + + if (this._isConnectorModel(model)) { + this._setConnectorEndpoints(model); + } + }) + ); + this._connectorIndexDisposables.add( + this.elementUpdated.subscribe(({ id, props }) => { + if (!props['source'] && !props['target']) { + return; + } + + const model = this.getElementById(id); + + if (this._isConnectorModel(model)) { + this._setConnectorEndpoints(model); + } + }) + ); + this._connectorIndexDisposables.add( + this.elementRemoved.subscribe(({ id, type }) => { + if (type === 'connector') { + this._removeConnectorFromIndex(id); + } + }) + ); + this.deleted.subscribe(() => { + this._connectorIndexDisposables.dispose(); + this._connectorIdsByEndpoint.clear(); + this._connectorEndpoints.clear(); + }); this.store.provider .getAll(surfaceMiddlewareIdentifier) .forEach(({ middleware }) => { @@ -52,13 +188,31 @@ export class SurfaceBlockModel extends BaseSurfaceModel { } getConnectors(id: string) { - const connectors = this.getElementsByType( - 'connector' - ) as unknown[] as ConnectorElementModel[]; + const connectorIds = this._connectorIdsByEndpoint.get(id); - return connectors.filter( - connector => connector.source?.id === id || connector.target?.id === id - ); + if (!connectorIds?.size) { + return []; + } + + const staleConnectorIds: string[] = []; + const connectors: ConnectorElementModel[] = []; + + connectorIds.forEach(connectorId => { + const model = this.getElementById(connectorId); + + if (!this._isConnectorModel(model)) { + staleConnectorIds.push(connectorId); + return; + } + + connectors.push(model); + }); + + staleConnectorIds.forEach(connectorId => { + this._removeConnectorFromIndex(connectorId); + }); + + return connectors; } override getElementsByType( diff --git a/blocksuite/affine/gfx/connector/src/connector-watcher.ts b/blocksuite/affine/gfx/connector/src/connector-watcher.ts index b58c0275c7..0d1a8c2387 100644 --- a/blocksuite/affine/gfx/connector/src/connector-watcher.ts +++ b/blocksuite/affine/gfx/connector/src/connector-watcher.ts @@ -84,6 +84,8 @@ export const connectorWatcher: SurfaceMiddleware = ( ); return () => { + pendingFlag = false; + pendingList.clear(); disposables.forEach(d => d.unsubscribe()); }; }; diff --git a/blocksuite/affine/gfx/group/package.json b/blocksuite/affine/gfx/group/package.json index a8bb715fc1..a1523fc775 100644 --- a/blocksuite/affine/gfx/group/package.json +++ b/blocksuite/affine/gfx/group/package.json @@ -26,6 +26,7 @@ "@preact/signals-core": "^1.8.0", "@toeverything/theme": "^1.1.23", "@types/lodash-es": "^4.17.12", + "fractional-indexing": "^3.2.0", "lit": "^3.2.0", "lodash-es": "^4.17.23", "minimatch": "^10.1.1", @@ -33,6 +34,9 @@ "yjs": "^13.6.27", "zod": "^3.25.76" }, + "devDependencies": { + "vitest": "^3.2.4" + }, "exports": { ".": "./src/index.ts", "./view": "./src/view.ts", diff --git a/blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts b/blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts new file mode 100644 index 0000000000..b2ed1e6cd1 --- /dev/null +++ b/blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts @@ -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([ + [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'); + }); +}); diff --git a/blocksuite/affine/gfx/group/src/command/group-api.ts b/blocksuite/affine/gfx/group/src/command/group-api.ts index a349d8590d..6426116900 100644 --- a/blocksuite/affine/gfx/group/src/command/group-api.ts +++ b/blocksuite/affine/gfx/group/src/command/group-api.ts @@ -4,7 +4,80 @@ import { MindmapElementModel, } from '@blocksuite/affine-model'; 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( + (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< { elements: GfxModel[] | string[] }, @@ -39,96 +112,118 @@ export const createGroupFromSelectedCommand: Command< {}, { groupId: string } > = (ctx, next) => { - const { std } = ctx; - const gfx = std.get(GfxControllerIdentifier); - const { selection, surface } = gfx; + measureOperation('edgeless:create-group-from-selected', () => { + const { std } = ctx; + const gfx = std.get(GfxControllerIdentifier); + const { selection, surface } = gfx; - if (!surface) { - return; - } + if (!surface) { + return; + } - if ( - selection.selectedElements.length === 0 || - !selection.selectedElements.every( - element => - element.group === selection.firstElement.group && - !(element.group instanceof MindmapElementModel) - ) - ) { - return; - } + if ( + selection.selectedElements.length === 0 || + !selection.selectedElements.every( + element => + element.group === selection.firstElement.group && + !(element.group instanceof MindmapElementModel) + ) + ) { + return; + } - const parent = selection.firstElement.group as GroupElementModel; + const parent = selection.firstElement.group; + let groupId: string | undefined; + std.store.transact(() => { + const [_, result] = std.command.exec(createGroupCommand, { + elements: selection.selectedElements, + }); - if (parent !== null) { - selection.selectedElements.forEach(element => { - // oxlint-disable-next-line unicorn/prefer-dom-node-remove - parent.removeChild(element); + if (!result.groupId) { + return; + } + + groupId = result.groupId; + const group = surface.getElementById(groupId); + + if (parent !== null && group) { + batchRemoveChildren(parent, selection.selectedElements); + batchAddChildren(parent, [group]); + } }); - } - const [_, result] = std.command.exec(createGroupCommand, { - elements: selection.selectedElements, + if (!groupId) { + return; + } + + selection.set({ + editing: false, + elements: [groupId], + }); + + next({ groupId }); }); - if (!result.groupId) { - return; - } - const group = surface.getElementById(result.groupId); - - if (parent !== null && group) { - parent.addChild(group); - } - - selection.set({ - editing: false, - elements: [result.groupId], - }); - - next({ groupId: result.groupId }); }; export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = ( ctx, next ) => { - const { std, group } = ctx; - const gfx = std.get(GfxControllerIdentifier); - const { selection } = gfx; - const parent = group.group as GroupElementModel; - const elements = group.childElements; + measureOperation('edgeless:ungroup', () => { + const { std, group } = ctx; + const gfx = std.get(GfxControllerIdentifier); + const { selection } = gfx; + const parent = group.group; + const elements = [...group.childElements]; - if (group instanceof MindmapElementModel) { - return; - } + if (group instanceof MindmapElementModel) { + return; + } - if (parent !== null) { - // oxlint-disable-next-line unicorn/prefer-dom-node-remove - parent.removeChild(group); - } + 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 + ); - elements.forEach(element => { - // oxlint-disable-next-line unicorn/prefer-dom-node-remove - group.removeChild(element); - }); + std.store.transact(() => { + if (parent !== null) { + batchRemoveChildren(parent, [group]); + } - // keep relative index order of group children after ungroup - elements - .sort((a, b) => gfx.layer.compare(a, b)) - .forEach(element => { - std.store.transact(() => { - element.index = gfx.layer.generateIndex(); + batchRemoveChildren(group, elements); + + // keep relative index order of group children after ungroup + orderedElements.forEach((element, idx) => { + const index = nextIndexes[idx]; + if (element.index !== index) { + element.index = index; + } }); + + if (parent !== null) { + batchAddChildren(parent, orderedElements); + } }); - if (parent !== null) { - elements.forEach(element => { - parent.addChild(element); + selection.set({ + editing: false, + elements: orderedElements.map(ele => ele.id), }); - } - - selection.set({ - editing: false, - elements: elements.map(ele => ele.id), + next(); }); - next(); }; diff --git a/blocksuite/affine/gfx/group/vitest.config.ts b/blocksuite/affine/gfx/group/vitest.config.ts new file mode 100644 index 0000000000..4f6a602d45 --- /dev/null +++ b/blocksuite/affine/gfx/group/vitest.config.ts @@ -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', + }, +}); diff --git a/blocksuite/affine/gfx/pointer/package.json b/blocksuite/affine/gfx/pointer/package.json index 1bd2c9c09e..bdd3fbd4d0 100644 --- a/blocksuite/affine/gfx/pointer/package.json +++ b/blocksuite/affine/gfx/pointer/package.json @@ -32,6 +32,9 @@ "yjs": "^13.6.27", "zod": "^3.25.76" }, + "devDependencies": { + "vitest": "^3.2.4" + }, "exports": { ".": "./src/index.ts", "./view": "./src/view.ts" diff --git a/blocksuite/affine/gfx/pointer/src/__tests__/adaptive-load-controller.unit.spec.ts b/blocksuite/affine/gfx/pointer/src/__tests__/adaptive-load-controller.unit.spec.ts new file mode 100644 index 0000000000..51e31d0d16 --- /dev/null +++ b/blocksuite/affine/gfx/pointer/src/__tests__/adaptive-load-controller.unit.spec.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); + }); +}); diff --git a/blocksuite/affine/gfx/pointer/src/__tests__/pan-tool.unit.spec.ts b/blocksuite/affine/gfx/pointer/src/__tests__/pan-tool.unit.spec.ts new file mode 100644 index 0000000000..6ebfcfed51 --- /dev/null +++ b/blocksuite/affine/gfx/pointer/src/__tests__/pan-tool.unit.spec.ts @@ -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; +}) => { + 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> = {}; + (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, + } + ); + }); +}); diff --git a/blocksuite/affine/gfx/pointer/src/snap/adaptive-load-controller.ts b/blocksuite/affine/gfx/pointer/src/snap/adaptive-load-controller.ts new file mode 100644 index 0000000000..9e1bd74809 --- /dev/null +++ b/blocksuite/affine/gfx/pointer/src/snap/adaptive-load-controller.ts @@ -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; + } +} diff --git a/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts b/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts index e90c69c962..2de9ff5244 100644 --- a/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts +++ b/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts @@ -8,11 +8,18 @@ import { InteractivityExtension, } from '@blocksuite/std/gfx'; +import { AdaptiveStrideController } from './adaptive-load-controller'; import type { SnapOverlay } from './snap-overlay'; export class SnapExtension extends InteractivityExtension { 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() { return this.std.getOptional( OverlayIdentifier('snap-manager') @@ -29,6 +36,11 @@ export class SnapExtension extends InteractivityExtension { } 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 { onDragStart() { @@ -42,6 +54,7 @@ export class SnapExtension extends InteractivityExtension { return pre; }, [] as GfxModel[]) ); + alignStride.reset(); }, onDragMove(context: ExtensionDragMoveContext) { if ( @@ -53,14 +66,22 @@ export class SnapExtension extends InteractivityExtension { return; } + if (alignStride.shouldSkip()) { + return; + } + const currentBound = alignBound.moveDelta(context.dx, context.dy); + const alignStart = performance.now(); const alignRst = snapOverlay.align(currentBound); + const alignCost = performance.now() - alignStart; + alignStride.reportCost(alignCost); context.dx = alignRst.dx + context.dx; context.dy = alignRst.dy + context.dy; }, clear() { alignBound = null; + alignStride.reset(); snapOverlay.clear(); }, }; diff --git a/blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts b/blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts index a18adfe2c8..edef85dd20 100644 --- a/blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts +++ b/blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts @@ -6,6 +6,8 @@ import { import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx'; import type { GfxModel } from '@blocksuite/std/gfx'; +import { AdaptiveCooldownController } from './adaptive-load-controller'; + interface Distance { horiz?: { /** @@ -35,6 +37,9 @@ interface Distance { const ALIGN_THRESHOLD = 8; const DISTRIBUTION_LINE_OFFSET = 1; 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 { static override overlayName: string = 'snap-manager'; @@ -75,6 +80,11 @@ export class SnapOverlay extends Overlay { vertical: [], }; + private readonly _distributeCooldown = new AdaptiveCooldownController({ + cooldownFrames: DISTRIBUTE_ALIGN_COOLDOWN_FRAMES, + maxCostMs: DISTRIBUTE_ALIGN_MAX_COST_MS, + }); + override clear() { this._referenceBounds = { vertical: [], @@ -87,6 +97,7 @@ export class SnapOverlay extends Overlay { }; this._distributedAlignLines = []; this._skippedElements.clear(); + this._distributeCooldown.reset(); super.clear(); } @@ -673,13 +684,24 @@ export class SnapOverlay extends Overlay { } } - // point align priority is higher than distribute align - if (rst.dx === 0) { - this._alignDistributeHorizontally(rst, bound, threshold, viewport); - } + const shouldTryDistribute = + this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES && + this._distributeCooldown.shouldRun(); - if (rst.dy === 0) { - this._alignDistributeVertically(rst, bound, threshold, viewport); + if (shouldTryDistribute) { + const distributeStart = performance.now(); + + // point align priority is higher than distribute align + if (rst.dx === 0) { + this._alignDistributeHorizontally(rst, bound, threshold, viewport); + } + + if (rst.dy === 0) { + this._alignDistributeVertically(rst, bound, threshold, viewport); + } + + const distributeCost = performance.now() - distributeStart; + this._distributeCooldown.reportCost(distributeCost); } this._renderer?.refresh(); @@ -776,24 +798,26 @@ export class SnapOverlay extends Overlay { }); const verticalBounds: Bound[] = []; const horizBounds: Bound[] = []; - const allBounds: Bound[] = []; + const allCandidateElements = new Set(); vertCandidates.forEach(candidate => { if (skipped.has(candidate) || this._isSkippedElement(candidate)) return; - verticalBounds.push(candidate.elementBound); - allBounds.push(candidate.elementBound); + const bound = candidate.elementBound; + verticalBounds.push(bound); + allCandidateElements.add(candidate); }); horizCandidates.forEach(candidate => { if (skipped.has(candidate) || this._isSkippedElement(candidate)) return; - horizBounds.push(candidate.elementBound); - allBounds.push(candidate.elementBound); + const bound = candidate.elementBound; + horizBounds.push(bound); + allCandidateElements.add(candidate); }); this._referenceBounds = { horizontal: horizBounds, vertical: verticalBounds, - all: allBounds, + all: [...allCandidateElements].map(element => element.elementBound), }; } diff --git a/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts b/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts index c1abe0aea7..dc61c57a40 100644 --- a/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts +++ b/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts @@ -4,7 +4,12 @@ import { } from '@blocksuite/affine-block-surface'; import { on } from '@blocksuite/affine-shared/utils'; 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'; interface RestorablePresentToolOptions { @@ -21,13 +26,30 @@ export class PanTool extends BaseTool { private _lastPoint: [number, number] | null = null; + private _pendingDelta: [number, number] = [0, 0]; + + private readonly _deltaFlushCoalescer = createRafCoalescer(() => { + this._flushPendingDelta(); + }); + readonly panning$ = new Signal(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 { return true; } override dragEnd(_: PointerEventState): void { + this._deltaFlushCoalescer.flush(); this._lastPoint = null; this.panning$.value = false; } @@ -43,12 +65,14 @@ export class PanTool extends BaseTool { const deltaY = lastY - e.y; this._lastPoint = [e.x, e.y]; - - viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom); + this._pendingDelta[0] += deltaX / zoom; + this._pendingDelta[1] += deltaY / zoom; + this._deltaFlushCoalescer.schedule(undefined); } override dragStart(e: PointerEventState): void { this._lastPoint = [e.x, e.y]; + this._pendingDelta = [0, 0]; this.panning$.value = true; } @@ -120,4 +144,8 @@ export class PanTool extends BaseTool { return false; }); } + + override unmounted(): void { + this._deltaFlushCoalescer.cancel(); + } } diff --git a/blocksuite/affine/gfx/pointer/vitest.config.ts b/blocksuite/affine/gfx/pointer/vitest.config.ts new file mode 100644 index 0000000000..0a0f315df8 --- /dev/null +++ b/blocksuite/affine/gfx/pointer/vitest.config.ts @@ -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', + }, +}); diff --git a/blocksuite/affine/model/src/blocks/frame/frame-model.ts b/blocksuite/affine/model/src/blocks/frame/frame-model.ts index b99a23b437..b8b78a435a 100644 --- a/blocksuite/affine/model/src/blocks/frame/frame-model.ts +++ b/blocksuite/affine/model/src/blocks/frame/frame-model.ts @@ -155,9 +155,22 @@ export class FrameBlockModel } 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.props.childElementIds && - delete this.props.childElementIds[element.id]; + const childElementIds = this.props.childElementIds; + if (!childElementIds) return; + + childIds.forEach(childId => { + delete childElementIds[childId]; + }); }); } } diff --git a/blocksuite/affine/model/src/elements/group/group.ts b/blocksuite/affine/model/src/elements/group/group.ts index bb1fba3621..345c93b655 100644 --- a/blocksuite/affine/model/src/elements/group/group.ts +++ b/blocksuite/affine/model/src/elements/group/group.ts @@ -54,12 +54,21 @@ export class GroupElementModel extends GfxGroupLikeElementModel + canSafeAddToContainer(this, element) + ); + if (elements.length === 0) { return; } this.surface.store.transact(() => { - this.children.set(element.id, true); + elements.forEach(element => { + this.children.set(element.id, true); + }); }); } @@ -76,11 +85,22 @@ export class GroupElementModel extends GfxGroupLikeElementModel element.id))]; + if (childIds.length === 0) { + return; + } + this.surface.store.transact(() => { - this.children.delete(element.id); + childIds.forEach(childId => { + this.children.delete(childId); + }); }); } diff --git a/blocksuite/docs/api/@blocksuite/std/gfx/README.md b/blocksuite/docs/api/@blocksuite/std/gfx/README.md index 0361c3e3f5..c8c4fd600a 100644 --- a/blocksuite/docs/api/@blocksuite/std/gfx/README.md +++ b/blocksuite/docs/api/@blocksuite/std/gfx/README.md @@ -34,6 +34,7 @@ - [canSafeAddToContainer](functions/canSafeAddToContainer.md) - [compareLayer](functions/compareLayer.md) - [convert](functions/convert.md) +- [createRafCoalescer](functions/createRafCoalescer.md) - [derive](functions/derive.md) - [generateKeyBetween](functions/generateKeyBetween.md) - [generateKeyBetweenV2](functions/generateKeyBetweenV2.md) @@ -42,5 +43,6 @@ - [GfxCompatible](functions/GfxCompatible.md) - [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md) - [local](functions/local.md) +- [measureOperation](functions/measureOperation.md) - [observe](functions/observe.md) - [watch](functions/watch.md) diff --git a/blocksuite/docs/api/@blocksuite/std/gfx/functions/createRafCoalescer.md b/blocksuite/docs/api/@blocksuite/std/gfx/functions/createRafCoalescer.md new file mode 100644 index 0000000000..6596492dee --- /dev/null +++ b/blocksuite/docs/api/@blocksuite/std/gfx/functions/createRafCoalescer.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`\> diff --git a/blocksuite/docs/api/@blocksuite/std/gfx/functions/measureOperation.md b/blocksuite/docs/api/@blocksuite/std/gfx/functions/measureOperation.md new file mode 100644 index 0000000000..e280899b24 --- /dev/null +++ b/blocksuite/docs/api/@blocksuite/std/gfx/functions/measureOperation.md @@ -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` diff --git a/blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts b/blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts index a056465c7d..0cf5734b0b 100644 --- a/blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts +++ b/blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts @@ -356,3 +356,63 @@ describe('convert decorator', () => { 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); + }); +}); diff --git a/blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts b/blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts new file mode 100644 index 0000000000..1e52c603d6 --- /dev/null +++ b/blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts @@ -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); + }); +}); diff --git a/blocksuite/framework/std/src/gfx/index.ts b/blocksuite/framework/std/src/gfx/index.ts index 7ec77a243e..691a889f59 100644 --- a/blocksuite/framework/std/src/gfx/index.ts +++ b/blocksuite/framework/std/src/gfx/index.ts @@ -5,6 +5,8 @@ export { SortOrder, } from '../utils/layer.js'; export { + batchAddChildren, + batchRemoveChildren, canSafeAddToContainer, descendantElementsImpl, getTopElements, @@ -94,6 +96,8 @@ export { type SurfaceBlockProps, type SurfaceMiddleware, } 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 { SurfaceMiddlewareBuilder, diff --git a/blocksuite/framework/std/src/gfx/interactivity/manager.ts b/blocksuite/framework/std/src/gfx/interactivity/manager.ts index 11e101583e..99f7462ed0 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/manager.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/manager.ts @@ -11,6 +11,7 @@ import { GfxExtension, GfxExtensionIdentifier } from '../extension.js'; import { GfxBlockElementModel } from '../model/gfx-block-model.js'; import type { GfxModel } from '../model/model.js'; import { GfxPrimitiveElementModel } from '../model/surface/element-model.js'; +import { createRafCoalescer } from '../raf-coalescer.js'; import type { GfxElementModelView } from '../view/view.js'; import { createInteractionContext, type SupportedEvents } from './event.js'; import { @@ -55,6 +56,20 @@ export const InteractivityIdentifier = GfxExtensionIdentifier( 'interactivity-manager' ) as ServiceIdentifier; +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 { static override key = 'interactivity-manager'; @@ -381,11 +396,18 @@ export class InteractivityManager extends GfxExtension { }; let dragLastPos = internal.dragStartPos; 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( this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y]) ); @@ -407,6 +429,16 @@ export class InteractivityManager extends GfxExtension { moveContext[direction] = 0; } + if ( + lastMoveDelta && + lastMoveDelta[0] === moveContext.dx && + lastMoveDelta[1] === moveContext.dy + ) { + return; + } + + lastMoveDelta = [moveContext.dx, moveContext.dy]; + this._safeExecute(() => { activeExtensionHandlers.forEach(handler => handler?.onDragMove?.(moveContext) @@ -423,13 +455,39 @@ export class InteractivityManager extends GfxExtension { elements: internal.elements, }); }); + + if ( + canCoalesceDragMove && + !shouldCoalesceDragMove && + performance.now() - moveStart > DRAG_MOVE_HEAVY_COST_MS + ) { + shouldCoalesceDragMove = true; + } }; + + const dragMoveCoalescer = createRafCoalescer(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) => { this.activeInteraction$.value = null; host.removeEventListener('pointermove', onDragMove, false); host.removeEventListener('pointerup', onDragEnd, false); viewportWatcher.unsubscribe(); + flushPendingDragMove(); dragLastPos = Point.from( this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y]) diff --git a/blocksuite/framework/std/src/gfx/layer.ts b/blocksuite/framework/std/src/gfx/layer.ts index e26a2134cc..4ee2f11e99 100644 --- a/blocksuite/framework/std/src/gfx/layer.ts +++ b/blocksuite/framework/std/src/gfx/layer.ts @@ -101,6 +101,8 @@ export class LayerManager extends GfxExtension { layers: Layer[] = []; + private readonly _groupChildSnapshot = new Map(); + slots = { layerUpdated: new Subject<{ type: 'delete' | 'add' | 'update'; @@ -148,6 +150,43 @@ export class LayerManager extends GfxExtension { : '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([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() { let blockIdx = 0; let canvasIdx = 0; @@ -487,6 +526,29 @@ export class LayerManager extends GfxExtension { 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() { const elements = ( this._doc @@ -512,6 +574,17 @@ export class LayerManager extends GfxExtension { this.canvasElements.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._buildCanvasLayers(); @@ -522,7 +595,8 @@ export class LayerManager extends GfxExtension { */ private _updateLayer( element: GfxModel | GfxLocalElementModel, - props?: Record + props?: Record, + oldValues?: Record ) { const modelType = this._getModelType(element); const isLocalElem = element instanceof GfxLocalElementModel; @@ -539,7 +613,16 @@ export class LayerManager extends GfxExtension { }; 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; } @@ -581,6 +664,13 @@ export class LayerManager extends GfxExtension { element ); } + + if (isContainer) { + this._syncGroupChildSnapshot( + element as GfxModel & GfxGroupCompatibleInterface + ); + } + this._insertIntoLayer(element as GfxModel, modelType); if (isContainer) { @@ -648,7 +738,26 @@ export class LayerManager extends GfxExtension { const isLocalElem = element instanceof GfxLocalElementModel; 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({ type: 'delete', initiatingElement: element as GfxModel, @@ -680,6 +789,7 @@ export class LayerManager extends GfxExtension { override unmounted() { this.slots.layerUpdated.complete(); + this._groupChildSnapshot.clear(); this._disposable.dispose(); } @@ -777,9 +887,10 @@ export class LayerManager extends GfxExtension { update( element: GfxModel | GfxLocalElementModel, - props?: Record + props?: Record, + oldValues?: Record ) { - if (this._updateLayer(element, props)) { + if (this._updateLayer(element, props, oldValues)) { this._buildCanvasLayers(); this.slots.layerUpdated.next({ type: 'update', @@ -867,7 +978,11 @@ export class LayerManager extends GfxExtension { this._disposable.add( surface.elementUpdated.subscribe(payload => { 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 + ); } }) ); diff --git a/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts b/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts index faeee1689a..5cc92fbc86 100644 --- a/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts +++ b/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts @@ -6,6 +6,7 @@ import { signal } from '@preact/signals-core'; import { Subject } from 'rxjs'; import * as Y from 'yjs'; +import { measureOperation } from '../../perf.js'; import { type GfxGroupCompatibleInterface, isGfxGroupCompatibleModel, @@ -74,6 +75,10 @@ export class SurfaceBlockModel extends BlockModel { protected _groupLikeModels = new Map(); + protected _parentGroupMap = new Map(); + + protected _groupChildIdsMap = new Map(); + protected _middlewares: SurfaceMiddleware[] = []; protected _surfaceBlockModel = true; @@ -133,6 +138,44 @@ export class SurfaceBlockModel extends BlockModel { }); } + private _collectElementsToDelete( + id: string, + deleteElementIds: Set, + orderedDeleteIds: string[], + deleteBlockIds: Set + ) { + 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( props: Record, options: { @@ -247,6 +290,26 @@ export class SurfaceBlockModel extends BlockModel { }; } + 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() { const elementsYMap = this.elements.getValue()!; const addToType = (type: string, model: GfxPrimitiveElementModel) => { @@ -260,6 +323,7 @@ export class SurfaceBlockModel extends BlockModel { if (isGfxGroupCompatibleModel(model)) { this._groupLikeModels.set(model.id, model); + this._syncGroupChildrenIndex(model.id, model.childIds, []); } }; const removeFromType = (type: string, model: GfxPrimitiveElementModel) => { @@ -270,7 +334,10 @@ export class SurfaceBlockModel extends BlockModel { 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); } }; @@ -304,9 +371,9 @@ export class SurfaceBlockModel extends BlockModel { element, { onChange: payload => { - this.elementUpdated.next(payload); - Object.keys(payload.props).forEach(key => { - model.model.propsUpdated.next({ key }); + this._emitElementUpdated(model.model, { + ...payload, + id, }); }, skipFieldInit: true, @@ -351,10 +418,10 @@ export class SurfaceBlockModel extends BlockModel { val, { onChange: payload => { - (this.elementUpdated.next(payload), - Object.keys(payload.props).forEach(key => { - model.model.propsUpdated.next({ key }); - })); + this._emitElementUpdated(model.model, { + ...payload, + id: key, + }); }, skipFieldInit: true, } @@ -371,9 +438,12 @@ export class SurfaceBlockModel extends BlockModel { Object.values(this.store.blocks.peek()).forEach(block => { if (isGfxGroupCompatibleModel(block.model)) { this._groupLikeModels.set(block.id, block.model); + this._syncGroupChildrenIndex(block.id, block.model.childIds, []); } }); + this._rebuildGroupChildrenIndex(); + elementsYMap.observe(onElementsMapChange); const subscription = this.store.slots.blockUpdated.subscribe(payload => { @@ -381,11 +451,17 @@ export class SurfaceBlockModel extends BlockModel { case 'add': if (isGfxGroupCompatibleModel(payload.model)) { this._groupLikeModels.set(payload.id, payload.model); + this._syncGroupChildrenIndex( + payload.id, + payload.model.childIds, + [] + ); } break; case 'delete': if (isGfxGroupCompatibleModel(payload.model)) { + this._removeGroupFromChildrenIndex(payload.id); this._groupLikeModels.delete(payload.id); } { @@ -395,6 +471,16 @@ export class SurfaceBlockModel extends BlockModel { 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; } @@ -403,6 +489,8 @@ export class SurfaceBlockModel extends BlockModel { this.deleted.subscribe(() => { elementsYMap.unobserve(onElementsMapChange); subscription.unsubscribe(); + this._groupChildIdsMap.clear(); + this._parentGroupMap.clear(); }); } @@ -500,6 +588,71 @@ export class SurfaceBlockModel extends BlockModel { 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 + ) { + 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>( props: Partial & { type: string } ) { @@ -526,9 +679,9 @@ export class SurfaceBlockModel extends BlockModel { const elementModel = this._createElementFromProps(props, { onChange: payload => { - this.elementUpdated.next(payload); - Object.keys(payload.props).forEach(key => { - elementModel.model.propsUpdated.next({ key }); + this._emitElementUpdated(elementModel.model, { + ...payload, + id, }); }, }); @@ -560,24 +713,48 @@ export class SurfaceBlockModel extends BlockModel { return; } - this.store.transact(() => { - const element = this.getElementById(id)!; - const group = this.getGroup(id); + measureOperation('edgeless:delete-element', () => { + const deleteElementIds = new Set(); + const orderedDeleteIds: string[] = []; + const deleteBlockIds = new Set(); - if (element instanceof GfxGroupLikeElementModel) { - element.childIds.forEach(childId => { - if (this.hasElementById(childId)) { - this.deleteElement(childId); - } else if (this.store.hasBlock(childId)) { - this.store.deleteBlock(this.store.getBlock(childId)!.model); - } - }); + this._collectElementsToDelete( + id, + deleteElementIds, + orderedDeleteIds, + deleteBlockIds + ); + + if (orderedDeleteIds.length === 0) { + return; } - // oxlint-disable-next-line unicorn/prefer-dom-node-remove - group?.removeChild(element as GfxModel); + this.store.transact(() => { + orderedDeleteIds.forEach(elementId => { + const element = this.getElementById(elementId); - this.elements.getValue()!.delete(id); + if (!element) { + return; + } + + this._removeFromParentGroupIfNeeded(element, deleteElementIds); + this.elements.getValue()!.delete(elementId); + }); + + deleteBlockIds.forEach(blockId => { + const block = this.store.getBlock(blockId)?.model; + + if (!block) { + return; + } + + this._removeFromParentGroupIfNeeded( + block as GfxModel, + deleteElementIds + ); + this.store.deleteBlock(block); + }); + }); }); } @@ -607,18 +784,31 @@ export class SurfaceBlockModel extends BlockModel { } 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' ? ((this.getElementById(elem) ?? this.store.getBlock(elem)?.model) as GfxModel) : elem; - if (!elem) return null; + if (!model) return null; - assertType(elem); + assertType(model); for (const group of this._groupLikeModels.values()) { - if (group.hasChild(elem)) { + if (group.hasChild(model)) { + this._parentGroupMap.set(id, group.id); return group; } } diff --git a/blocksuite/framework/std/src/gfx/perf.ts b/blocksuite/framework/std/src/gfx/perf.ts new file mode 100644 index 0000000000..f18d1d06da --- /dev/null +++ b/blocksuite/framework/std/src/gfx/perf.ts @@ -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 = (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); + } +}; diff --git a/blocksuite/framework/std/src/gfx/raf-coalescer.ts b/blocksuite/framework/std/src/gfx/raf-coalescer.ts new file mode 100644 index 0000000000..35c4162641 --- /dev/null +++ b/blocksuite/framework/std/src/gfx/raf-coalescer.ts @@ -0,0 +1,76 @@ +export interface RafCoalescer { + 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 = ( + apply: (payload: T) => void +): RafCoalescer => { + 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; + }, + }; +}; diff --git a/blocksuite/framework/std/src/gfx/viewport-element.ts b/blocksuite/framework/std/src/gfx/viewport-element.ts index 42bb42aaa8..ad7e6775d8 100644 --- a/blocksuite/framework/std/src/gfx/viewport-element.ts +++ b/blocksuite/framework/std/src/gfx/viewport-element.ts @@ -41,6 +41,10 @@ export function requestThrottledConnectedFrame< viewport: PropTypes.instanceOf(Viewport), }) 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` gfx-viewport { position: absolute; @@ -104,6 +108,14 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) { private _lastVisibleModels?: Set; + private _lastViewportUpdate?: { zoom: number; center: [number, number] }; + + private _lastViewportRefreshTime = 0; + + private _pendingViewportRefreshTimer: ReturnType< + typeof globalThis.setTimeout + > | null = null; + private readonly _pendingChildrenUpdates: { id: string; resolve: () => void; @@ -115,26 +127,90 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) { 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 { super.connectedCallback(); - const viewportUpdateCallback = () => { - this._refreshViewport(); - }; - if (!this.enableChildrenSchedule) { delete this.scheduleUpdateChildren; } this._hideOutsideAndNoSelectedBlock(); this.disposables.add( - this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback()) + this.viewport.viewportUpdated.subscribe(update => + this._refreshViewportByViewportUpdate(update) + ) ); 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() { return html``; } diff --git a/blocksuite/framework/std/src/utils/tree.ts b/blocksuite/framework/std/src/utils/tree.ts index 7fbffa0b8f..94506b7a53 100644 --- a/blocksuite/framework/std/src/utils/tree.ts +++ b/blocksuite/framework/std/src/utils/tree.ts @@ -7,6 +7,11 @@ import { } from '../gfx/model/base.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. * @@ -26,19 +31,64 @@ import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js'; * The result should be `[G1, G4, E6]` */ 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 => { - elements.forEach(e2 => { - if (isGfxGroupCompatibleModel(e1) && e1.hasDescendant(e2)) { - results.delete(e2); + while (ancestor) { + if (selected.has(ancestor as GfxModel)) { + hasSelectedAncestor = true; + 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( diff --git a/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts index 0652c107be..6ae5d6d2c5 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts @@ -235,6 +235,69 @@ describe('connector', () => { 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 () => { const id = model.addElement({ type: 'shape', diff --git a/yarn.lock b/yarn.lock index 86ee7b2621..295c1f043e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2684,10 +2684,12 @@ __metadata: "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.23" "@types/lodash-es": "npm:^4.17.12" + fractional-indexing: "npm:^3.2.0" lit: "npm:^3.2.0" lodash-es: "npm:^4.17.23" minimatch: "npm:^10.1.1" rxjs: "npm:^7.8.2" + vitest: "npm:^3.2.4" yjs: "npm:^13.6.27" zod: "npm:^3.25.76" languageName: unknown @@ -2814,6 +2816,7 @@ __metadata: lodash-es: "npm:^4.17.23" minimatch: "npm:^10.1.1" rxjs: "npm:^7.8.2" + vitest: "npm:^3.2.4" yjs: "npm:^13.6.27" zod: "npm:^3.25.76" languageName: unknown