mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-26 02:35:58 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
} 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,
|
||||
|
||||
@@ -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<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 {
|
||||
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<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) => {
|
||||
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])
|
||||
|
||||
@@ -101,6 +101,8 @@ export class LayerManager extends GfxExtension {
|
||||
|
||||
layers: Layer[] = [];
|
||||
|
||||
private readonly _groupChildSnapshot = new Map<string, string[]>();
|
||||
|
||||
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<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() {
|
||||
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<string, unknown>
|
||||
props?: Record<string, unknown>,
|
||||
oldValues?: Record<string, unknown>
|
||||
) {
|
||||
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<string, unknown>
|
||||
props?: Record<string, unknown>,
|
||||
oldValues?: Record<string, unknown>
|
||||
) {
|
||||
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
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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<SurfaceBlockProps> {
|
||||
|
||||
protected _groupLikeModels = new Map<string, GfxGroupModel>();
|
||||
|
||||
protected _parentGroupMap = new Map<string, string>();
|
||||
|
||||
protected _groupChildIdsMap = new Map<string, string[]>();
|
||||
|
||||
protected _middlewares: SurfaceMiddleware[] = [];
|
||||
|
||||
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(
|
||||
props: Record<string, unknown>,
|
||||
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() {
|
||||
const elementsYMap = this.elements.getValue()!;
|
||||
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||
@@ -260,6 +323,7 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
|
||||
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<SurfaceBlockProps> {
|
||||
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<SurfaceBlockProps> {
|
||||
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<SurfaceBlockProps> {
|
||||
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<SurfaceBlockProps> {
|
||||
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<SurfaceBlockProps> {
|
||||
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<SurfaceBlockProps> {
|
||||
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<SurfaceBlockProps> {
|
||||
this.deleted.subscribe(() => {
|
||||
elementsYMap.unobserve(onElementsMapChange);
|
||||
subscription.unsubscribe();
|
||||
this._groupChildIdsMap.clear();
|
||||
this._parentGroupMap.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -500,6 +588,71 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
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>>(
|
||||
props: Partial<T> & { type: string }
|
||||
) {
|
||||
@@ -526,9 +679,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
|
||||
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<SurfaceBlockProps> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.transact(() => {
|
||||
const element = this.getElementById(id)!;
|
||||
const group = this.getGroup(id);
|
||||
measureOperation('edgeless:delete-element', () => {
|
||||
const deleteElementIds = new Set<string>();
|
||||
const orderedDeleteIds: string[] = [];
|
||||
const deleteBlockIds = new Set<string>();
|
||||
|
||||
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<SurfaceBlockProps> {
|
||||
}
|
||||
|
||||
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<GfxModel>(elem);
|
||||
assertType<GfxModel>(model);
|
||||
|
||||
for (const group of this._groupLikeModels.values()) {
|
||||
if (group.hasChild(elem)) {
|
||||
if (group.hasChild(model)) {
|
||||
this._parentGroupMap.set(id, group.id);
|
||||
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),
|
||||
})
|
||||
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<GfxBlockElementModel>;
|
||||
|
||||
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``;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user