feat(editor): improve select perf (#15353)

maybe fix #12675


#### PR Dependency Tree


* **PR #15353** 👈

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

- **Bug Fixes**
- Improved block selection updates so selected states refresh reliably.
  - Corrected selected-block ordering and duplicate handling.
- Improved toolbar positioning accuracy and reduced unnecessary layout
recalculations.
  - Adjusted toolbar animation behavior for surface-based tools.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-28 11:38:00 +08:00
committed by GitHub
parent cfc7bbb90f
commit e7ec8a1032
4 changed files with 39 additions and 31 deletions
@@ -1,4 +1,5 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { watch } from '@blocksuite/global/lit';
import { BlockComponent, type BlockService } from '@blocksuite/std'; import { BlockComponent, type BlockService } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store'; import type { BlockModel } from '@blocksuite/store';
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
@@ -59,7 +60,7 @@ export class CaptionedBlockComponent<
: nothing} : nothing}
${this.selectedStyle === SelectedStyle.Background ${this.selectedStyle === SelectedStyle.Background
? html`<affine-block-selection ? html`<affine-block-selection
.selected=${this.selected$.value} .selected=${watch(this.selected$)}
></affine-block-selection>` ></affine-block-selection>`
: null} : null}
${this.useZeroWidth && !this.store.readonly ${this.useZeroWidth && !this.store.readonly
@@ -5,7 +5,7 @@ import type {
TextSelection, TextSelection,
} from '@blocksuite/std'; } from '@blocksuite/std';
import { BlockComponent } from '@blocksuite/std'; import { BlockComponent } from '@blocksuite/std';
import type { RoleType } from '@blocksuite/store'; import type { BlockModel, RoleType } from '@blocksuite/store';
import type { ImageSelection } from '../../selection/index.js'; import type { ImageSelection } from '../../selection/index.js';
@@ -129,35 +129,33 @@ export const getSelectedBlocksCommand: Command<
dirtyResult = dirtyResult.filter(ctx.filter); dirtyResult = dirtyResult.filter(ctx.filter);
} }
const getModelPath = (el: BlockComponent) => { const seen = new Set<BlockComponent>();
const path: number[] = [];
let model = el.model;
while (model) {
const parent = ctx.std.store.getParent(model.id);
if (!parent) break;
path.unshift(parent.children.findIndex(child => child.id === model.id));
model = parent;
}
return path;
};
const compareByModelPath = (a: BlockComponent, b: BlockComponent) => {
if (a === b) return 0;
const aPath = getModelPath(a);
const bPath = getModelPath(b);
const length = Math.min(aPath.length, bPath.length);
for (let i = 0; i < length; i++) {
const diff = aPath[i] - bPath[i];
if (diff !== 0) return diff;
}
return aPath.length - bPath.length;
};
// remove duplicate elements // remove duplicate elements
const result: BlockComponent[] = dirtyResult const result: BlockComponent[] = dirtyResult.filter(el => {
.filter((el, index) => dirtyResult.indexOf(el) === index) if (seen.has(el)) return false;
seen.add(el);
return true;
});
if (result.length > 1) {
const modelOrder = new Map<string, number>();
const visit = (model: BlockModel) => {
modelOrder.set(model.id, modelOrder.size);
model.children.forEach(visit);
};
const root = ctx.std.store.root;
if (root) {
visit(root);
}
// sort by model tree position, which is the order used for paste/export // sort by model tree position, which is the order used for paste/export
.sort(compareByModelPath); result.sort(
(a, b) =>
(modelOrder.get(a.blockId) ?? Number.MAX_SAFE_INTEGER) -
(modelOrder.get(b.blockId) ?? Number.MAX_SAFE_INTEGER)
);
}
if (result.length === 0) return; if (result.length === 0) return;
@@ -146,7 +146,16 @@ export class AffineToolbarWidget extends WidgetComponent {
} }
setReferenceElementWithBlocks(blocks: BlockComponent[]) { setReferenceElementWithBlocks(blocks: BlockComponent[]) {
const getClientRects = () => blocks.map(e => e.getBoundingClientRect()); let cachedClientRects: DOMRect[] | null = null;
const getClientRects = () => {
if (!cachedClientRects) {
cachedClientRects = blocks.map(e => e.getBoundingClientRect());
requestAnimationFrame(() => {
cachedClientRects = null;
});
}
return cachedClientRects;
};
this.referenceElement$.value = blocks.length this.referenceElement$.value = blocks.length
? () => ({ ? () => ({
@@ -56,7 +56,7 @@ export function autoUpdatePosition(
flavour: string, flavour: string,
placement: ToolbarPlacement, placement: ToolbarPlacement,
sideOptions: Partial<SideObject> | null, sideOptions: Partial<SideObject> | null,
options: AutoUpdateOptions = { elementResize: false, animationFrame: true } options: AutoUpdateOptions = { elementResize: false }
) { ) {
const isInline = flavour === 'affine:note'; const isInline = flavour === 'affine:note';
const hasSurfaceScope = flavour.includes('surface'); const hasSurfaceScope = flavour.includes('surface');
@@ -147,7 +147,7 @@ export function autoUpdatePosition(
() => { () => {
update().catch(console.error); update().catch(console.error);
}, },
options { animationFrame: hasSurfaceScope, ...options }
); );
} }