perf(editor): optimize the search for the closest element (#9587)

Closes: [BS-2275](https://linear.app/affine-design/issue/BS-2275/拖拽-indicator-查找优化)
This commit is contained in:
fundon
2025-01-10 08:21:10 +00:00
parent 18ff7500c8
commit 0b3dba614d
11 changed files with 211 additions and 187 deletions

View File

@@ -21,12 +21,11 @@ export class AttachmentBlockService extends BlockService {
export const AttachmentDropOption = FileDropConfigExtension({
flavour: AttachmentBlockSchema.model.flavour,
onDrop: ({ files, targetModel, place, point, std }) => {
onDrop: ({ files, targetModel, placement, point, std }) => {
// generic attachment block for all files except images
const attachmentFiles = files.filter(
file => !file.type.startsWith('image/')
);
if (!attachmentFiles.length) return false;
if (targetModel && !matchFlavours(targetModel, ['affine:surface'])) {
@@ -36,7 +35,7 @@ export const AttachmentDropOption = FileDropConfigExtension({
// TODO: use max file size from service
maxFileSize,
targetModel,
place
placement
).catch(console.error);
return true;

View File

@@ -262,7 +262,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<
const model = this.doc.getBlock(id)?.model;
const target = result.modelState.model;
let parent = this.doc.getParent(target.id);
const shouldInsertIn = result.type === 'in';
const shouldInsertIn = result.placement === 'in';
if (shouldInsertIn) {
parent = target;
}
@@ -274,7 +274,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<
[model],
parent,
target,
result.type === 'before'
result.placement === 'before'
);
}
}

View File

@@ -24,7 +24,7 @@ export class ImageBlockService extends BlockService {
export const ImageDropOption = FileDropConfigExtension({
flavour: ImageBlockSchema.model.flavour,
onDrop: ({ files, targetModel, place, point, std }) => {
onDrop: ({ files, targetModel, placement, point, std }) => {
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (!imageFiles.length) return false;
@@ -35,7 +35,7 @@ export const ImageDropOption = FileDropConfigExtension({
// TODO: use max file size from service
maxFileSize,
targetModel,
place
placement
);
return true;
}

View File

@@ -1,40 +1,44 @@
import {
calcDropTarget,
type DropResult,
type DropTarget,
getClosestBlockComponentByPoint,
isInsidePageEditor,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
type BlockStdScope,
type EditorHost,
LifeCycleWatcher,
} from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { IVec } from '@blocksuite/global/utils';
import { Point } from '@blocksuite/global/utils';
import { Point, throttle } from '@blocksuite/global/utils';
import type { BlockModel, ExtensionType } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import type { DragIndicator } from './index.js';
import type { DragIndicator } from './drag-indicator';
export type onDropProps = {
export type DropProps = {
std: BlockStdScope;
files: File[];
targetModel: BlockModel | null;
place: 'before' | 'after';
placement: 'before' | 'after';
point: IVec;
};
export type FileDropOptions = {
flavour: string;
onDrop?: (onDropProps: onDropProps) => boolean;
onDrop?: (props: DropProps) => boolean;
};
/**
* Handles resources from outside.
* Uses `drag over` to handle it.
*/
export class FileDropExtension extends LifeCycleWatcher {
static override readonly key = 'FileDropExtension';
static dropResult: DropResult | null = null;
static get indicator() {
let indicator = document.querySelector<DragIndicator>(
'affine-drag-indicator'
@@ -50,73 +54,103 @@ export class FileDropExtension extends LifeCycleWatcher {
return indicator;
}
onDragLeave = () => {
FileDropExtension.dropResult = null;
FileDropExtension.indicator.rect = null;
};
point$ = signal<Point>(new Point(0, 0));
onDragMove = (event: DragEvent) => {
event.preventDefault();
closestElement$ = signal<BlockComponent | null>(null);
dropTarget$ = computed<DropTarget | null>(() => {
let target = null;
const element = this.closestElement$.value;
if (!element) return null;
const model = element.model;
const parent = this.std.store.getParent(model);
if (!matchFlavours(parent, ['affine:surface' as BlockSuite.Flavour])) {
const point = this.point$.value;
target = calcDropTarget(point, model, element);
}
return target;
});
getDropTargetModel(model: BlockModel | null) {
// Existed or In Edgeless
if (model || !isInsidePageEditor(this.editorHost)) return model;
const rootModel = this.doc.root;
if (!rootModel) return null;
let lastNote = rootModel.children[rootModel.children.length - 1];
if (!lastNote || !matchFlavours(lastNote, ['affine:note'])) {
const newNoteId = this.doc.addBlock('affine:note', {}, rootModel.id);
const newNote = this.doc.getBlock(newNoteId)?.model;
if (!newNote) return null;
lastNote = newNote;
}
const lastItem = lastNote.children[lastNote.children.length - 1];
if (lastItem) {
model = lastItem;
} else {
const newParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
lastNote,
0
);
model = this.doc.getBlock(newParagraphId)?.model ?? null;
}
return model;
}
shouldIgnoreEvent = (event: DragEvent, shouldCheckFiles?: boolean) => {
const dataTransfer = event.dataTransfer;
if (!dataTransfer) return;
if (!dataTransfer) return true;
const effectAllowed = dataTransfer.effectAllowed;
if (effectAllowed === 'none') return;
if (effectAllowed === 'none') return true;
const { clientX, clientY } = event;
const point = new Point(clientX, clientY);
const element = getClosestBlockComponentByPoint(point.clone());
if (!shouldCheckFiles) return false;
let result: DropResult | null = null;
if (element) {
const model = element.model;
const parent = this.std.store.getParent(model);
if (!matchFlavours(parent, ['affine:surface' as BlockSuite.Flavour])) {
result = calcDropTarget(point, model, element);
}
}
if (result) {
FileDropExtension.dropResult = result;
FileDropExtension.indicator.rect = result.rect;
} else {
FileDropExtension.dropResult = null;
FileDropExtension.indicator.rect = null;
}
const droppedFiles = dataTransfer.files;
if (!droppedFiles || !droppedFiles.length) return true;
return false;
};
get targetModel(): BlockModel | null {
let targetModel = FileDropExtension.dropResult?.modelState.model || null;
updatePoint = (event: DragEvent) => {
const { clientX, clientY } = event;
const oldPoint = this.point$.peek();
if (!targetModel && isInsidePageEditor(this.editorHost)) {
const rootModel = this.doc.root;
if (!rootModel) return null;
if (
Math.round(oldPoint.x) === Math.round(clientX) &&
Math.round(oldPoint.y) === Math.round(clientY)
)
return;
let lastNote = rootModel.children[rootModel.children.length - 1];
if (!lastNote || !matchFlavours(lastNote, ['affine:note'])) {
const newNoteId = this.doc.addBlock('affine:note', {}, rootModel.id);
const newNote = this.doc.getBlockById(newNoteId);
if (!newNote) return null;
lastNote = newNote;
}
this.point$.value = new Point(clientX, clientY);
};
const lastItem = lastNote.children[lastNote.children.length - 1];
if (lastItem) {
targetModel = lastItem;
} else {
const newParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
lastNote,
0
);
const newParagraph = this.doc.getBlockById(newParagraphId);
if (!newParagraph) return null;
targetModel = newParagraph;
}
}
return targetModel;
}
onDragLeave = () => {
this.closestElement$.value = null;
};
onDragOver = (event: DragEvent) => {
event.preventDefault();
if (this.shouldIgnoreEvent(event)) return;
this.updatePoint(event);
};
onDrop = (event: DragEvent) => {
event.preventDefault();
if (this.shouldIgnoreEvent(event, true)) return;
this.updatePoint(event);
};
get doc() {
return this.std.store;
@@ -126,66 +160,34 @@ export class FileDropExtension extends LifeCycleWatcher {
return this.std.host;
}
get type(): 'before' | 'after' {
return !FileDropExtension.dropResult ||
FileDropExtension.dropResult.type !== 'before'
? 'after'
: 'before';
}
private readonly _onDrop = (event: DragEvent, options: FileDropOptions) => {
FileDropExtension.indicator.rect = null;
const { onDrop } = options;
if (!onDrop) return;
const dataTransfer = event.dataTransfer;
if (!dataTransfer) return;
const effectAllowed = dataTransfer.effectAllowed;
if (effectAllowed === 'none') return;
const droppedFiles = dataTransfer.files;
if (!droppedFiles || !droppedFiles.length) return;
const { clientX, clientY } = event;
const point = new Point(clientX, clientY);
const element = getClosestBlockComponentByPoint(point.clone());
let result: DropResult | null = null;
if (element) {
const model = element.model;
const parent = this.std.store.getParent(model);
if (!matchFlavours(parent, ['affine:surface' as BlockSuite.Flavour])) {
result = calcDropTarget(point, model, element);
}
}
FileDropExtension.dropResult = result;
const { x, y } = event;
const { targetModel, type: place } = this;
const drop = onDrop({
std: this.std,
files: [...droppedFiles],
targetModel,
place,
point: [x, y],
});
if (drop) {
event.preventDefault();
}
return drop;
};
override mounted() {
super.mounted();
const std = this.std;
std.event.disposables.add(
std.event.add('nativeDragMove', context => {
this.point$.subscribe(
throttle(
value => {
if (value.x * value.y === 0) return;
this.closestElement$.value = getClosestBlockComponentByPoint(value);
},
233,
{ leading: true, trailing: true }
)
)
);
std.event.disposables.add(
this.dropTarget$.subscribe(target => {
FileDropExtension.indicator.rect = target?.rect ?? null;
})
);
std.event.disposables.add(
std.event.add('nativeDragOver', context => {
const event = context.get('dndState');
this.onDragMove(event.raw);
this.onDragOver(event.raw);
})
);
std.event.disposables.add(
@@ -195,19 +197,43 @@ export class FileDropExtension extends LifeCycleWatcher {
);
std.event.disposables.add(
std.event.add('nativeDrop', context => {
const event = context.get('dndState').raw;
const { x, y, dataTransfer } = event;
const droppedFiles = dataTransfer?.files;
if (!droppedFiles || !droppedFiles.length) {
this.onDragLeave();
return;
}
this.onDrop(event);
const target = this.dropTarget$.peek();
const std = this.std;
const targetModel = this.getDropTargetModel(
target?.modelState.model ?? null
);
const placement = target?.placement === 'before' ? 'before' : 'after';
const values = std.provider
.getAll(FileDropConfigExtensionIdentifier)
.values();
for (const value of values) {
if (value.onDrop) {
const event = context.get('dndState');
const drop = this._onDrop(event.raw, value);
if (drop) {
return;
}
}
for (const ext of values) {
if (!ext.onDrop) continue;
const options = {
std,
files: [...droppedFiles],
targetModel,
placement,
point: [x, y],
} satisfies DropProps;
if (ext.onDrop(options)) break;
}
this.onDragLeave();
})
);
}

View File

@@ -1,10 +1,10 @@
import { DragIndicator } from './drag-indicator.js';
import { DragIndicator } from './drag-indicator';
export {
type DropProps,
FileDropConfigExtension,
FileDropExtension,
type FileDropOptions,
type onDropProps,
} from './file-drop-manager.js';
} from './file-drop-manager';
export { DragIndicator };

View File

@@ -9,7 +9,7 @@ import {
} from '../dom/index.js';
import { matchFlavours } from '../model/index.js';
import { getDropRectByPoint } from './get-drop-rect-by-point.js';
import { DropFlags, type DroppingType, type DropResult } from './types.js';
import { DropFlags, type DropPlacement, type DropTarget } from './types.js';
function getVisiblePreviousElementSibling(element: Element | null) {
if (!element) return null;
@@ -43,7 +43,7 @@ export function calcDropTarget(
* Allow the dragging block to be dropped as sublist
*/
allowSublist: boolean = true
): DropResult | null {
): DropTarget | null {
const schema = model.doc.getSchemaByFlavour('affine:database');
const children = schema?.model.children ?? [];
@@ -64,7 +64,7 @@ export function calcDropTarget(
}
}
let type: DroppingType = 'none';
let placement: DropPlacement = 'none';
const height = 3 * scale;
const dropResult = getDropRectByPoint(point, model, element);
if (!dropResult) return null;
@@ -75,10 +75,10 @@ export function calcDropTarget(
const rect = Rect.fromDOMRect(domRect);
rect.top -= height / 2;
rect.height = height;
type = 'database';
placement = 'database';
return {
type,
placement,
rect,
modelState: {
model,
@@ -91,10 +91,10 @@ export function calcDropTarget(
const distanceToTop = Math.abs(domRect.top - point.y);
const distanceToBottom = Math.abs(domRect.bottom - point.y);
const before = distanceToTop < distanceToBottom;
type = before ? 'before' : 'after';
placement = before ? 'before' : 'after';
return {
type,
placement,
rect: Rect.fromLWTH(
domRect.left,
domRect.width,
@@ -113,10 +113,10 @@ export function calcDropTarget(
const distanceToBottom = Math.abs(domRect.bottom - point.y);
const before = distanceToTop < distanceToBottom;
type = before ? 'before' : 'after';
placement = before ? 'before' : 'after';
let offsetY = 4;
if (type === 'before') {
if (placement === 'before') {
// before
let prev;
let prevRect;
@@ -127,7 +127,7 @@ export function calcDropTarget(
draggingElements.length &&
prev === draggingElements[draggingElements.length - 1]
) {
type = 'none';
placement = 'none';
} else {
prevRect = getRectByBlockComponent(prev);
}
@@ -153,7 +153,7 @@ export function calcDropTarget(
!hasChild &&
point.x > domRect.x + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT
) {
type = 'in';
placement = 'in';
}
// after
let next;
@@ -162,11 +162,11 @@ export function calcDropTarget(
next = getVisibleNextElementSibling(element);
if (next) {
if (
type === 'after' &&
placement === 'after' &&
draggingElements.length &&
next === draggingElements[0]
) {
type = 'none';
placement = 'none';
next = null;
}
} else {
@@ -181,22 +181,22 @@ export function calcDropTarget(
}
}
if (type === 'none') return null;
if (placement === 'none') return null;
let top = domRect.top;
if (type === 'before') {
if (placement === 'before') {
top -= offsetY;
} else {
top += domRect.height + offsetY;
}
if (type === 'in') {
if (placement === 'in') {
domRect.x += BLOCK_CHILDREN_CONTAINER_PADDING_LEFT;
domRect.width -= BLOCK_CHILDREN_CONTAINER_PADDING_LEFT;
}
return {
type,
placement,
rect: Rect.fromLWTH(domRect.left, domRect.width, top - height / 2, height),
modelState: {
model,

View File

@@ -18,12 +18,12 @@ export enum DropFlags {
}
/**
* A dropping type.
* A drop placement.
*/
export type DroppingType = 'none' | 'before' | 'after' | 'database' | 'in';
export type DropPlacement = 'none' | 'before' | 'after' | 'database' | 'in';
export type DropResult = {
type: DroppingType;
export type DropTarget = {
placement: DropPlacement;
rect: Rect;
modelState: EditingState;
};

View File

@@ -146,8 +146,8 @@ export function getClosestBlockComponentByPoint(
}
}
} else {
// Indented paragraphs or list
bounds = getRectByBlockComponent(element);
// Indented paragraphs or list
childBounds = element
.querySelector('.affine-block-children-container')
?.firstElementChild?.getBoundingClientRect();
@@ -263,8 +263,8 @@ export function getClosestBlockComponentByElement(
* https://github.com/toeverything/blocksuite/pull/1121
*/
export function getRectByBlockComponent(element: Element | BlockComponent) {
if (isDatabase(element)) return element.getBoundingClientRect();
return (element.firstElementChild ?? element).getBoundingClientRect();
if (!isDatabase(element)) element = element.firstElementChild ?? element;
return element.getBoundingClientRect();
}
/**
@@ -303,9 +303,8 @@ function findBlockComponent(elements: Element[], parent?: Element) {
if (hasBlockId(element) && isBlock(element)) return element;
if (isImage(element)) {
const element = elements[i];
if (i < len && hasBlockId(element) && isBlock(element)) {
return elements[i];
}
if (!element) return null;
if (hasBlockId(element) && isBlock(element)) return element;
return getClosestBlockComponentByElement(element);
}
}

View File

@@ -4,8 +4,8 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
autoScroll,
calcDropTarget,
type DroppingType,
type DropResult,
type DropPlacement,
type DropTarget,
getScrollContainer,
isInsideEdgelessEditor,
isInsidePageEditor,
@@ -61,9 +61,9 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
/**
* When dragging, should update indicator position and target drop block id
*/
private readonly _getDropResult = (
private readonly _getDropTarget = (
state: DndEventState
): DropResult | null => {
): DropTarget | null => {
const point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint(
this.host,
@@ -114,7 +114,7 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
isDraggedElementNote === false
);
if (isDraggedElementNote && result?.type === 'in') return null;
if (isDraggedElementNote && result?.placement === 'in') return null;
return result;
};
@@ -135,7 +135,7 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
private readonly _reset = () => {
this.draggingElements = [];
this.dropBlockId = '';
this.dropType = null;
this.dropPlacement = null;
this.lastDragPointerState = null;
this.rafID = 0;
this.dragging = false;
@@ -157,29 +157,29 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
document.documentElement.classList.remove('affine-drag-preview-grabbing');
};
private readonly _resetDropResult = () => {
private readonly _resetDropTarget = () => {
this.dropBlockId = '';
this.dropType = null;
this.dropPlacement = null;
if (this.dropIndicator) this.dropIndicator.rect = null;
};
private readonly _updateDropResult = (dropResult: DropResult | null) => {
private readonly _updateDropTarget = (dropTarget: DropTarget | null) => {
if (!this.dropIndicator) return;
this.dropBlockId = dropResult?.modelState.model.id ?? '';
this.dropType = dropResult?.type ?? null;
if (dropResult?.rect) {
this.dropBlockId = dropTarget?.modelState.model.id ?? '';
this.dropPlacement = dropTarget?.placement ?? null;
if (dropTarget?.rect) {
const offsetParentRect =
this.dragHandleContainerOffsetParent.getBoundingClientRect();
let { left, top } = dropResult.rect;
let { left, top } = dropTarget.rect;
left -= offsetParentRect.left;
top -= offsetParentRect.top;
const { width, height } = dropResult.rect;
const { width, height } = dropTarget.rect;
const rect = Rect.fromLWTH(left, width, top, height);
this.dropIndicator.rect = rect;
} else {
this.dropIndicator.rect = dropResult?.rect ?? null;
this.dropIndicator.rect = dropTarget?.rect ?? null;
}
};
@@ -221,7 +221,7 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
dropIndicator: DropIndicator | null = null;
dropType: DroppingType | null = null;
dropPlacement: DropPlacement | null = null;
edgelessWatcher = new EdgelessWatcher(this);
@@ -298,10 +298,10 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
!closestNoteBlock ||
isOutOfNoteBlock(this.host, closestNoteBlock, point, this.scale.peek())
) {
this._resetDropResult();
this._resetDropTarget();
} else {
const dropResult = this._getDropResult(state);
this._updateDropResult(dropResult);
const dropTarget = this._getDropTarget(state);
this._updateDropTarget(dropTarget);
}
this.lastDragPointerState = state;

View File

@@ -3,7 +3,7 @@ import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
calcDropTarget,
type DropResult,
type DropTarget,
findClosestBlockComponent,
getBlockProps,
getClosestBlockComponentByPoint,
@@ -174,7 +174,7 @@ export const getClosestBlockByPoint = (
export const getDropResult = (
event: MouseEvent,
scale: number = 1
): DropResult | null => {
): DropTarget | null => {
let dropIndicator = null;
const point = new Point(event.x, event.y);
const closestBlock = getClosestBlockComponentByPoint(point) as BlockComponent;

View File

@@ -16,7 +16,7 @@ import {
import {
calcDropTarget,
captureEventTarget,
type DropResult,
type DropTarget,
getBlockComponentsExcludeSubtrees,
getClosestBlockComponentByPoint,
matchFlavours,
@@ -290,11 +290,11 @@ export class DragEventWatcher {
if (matchFlavours(parent, ['affine:surface'])) {
return;
}
const result: DropResult | null = calcDropTarget(point, model, element);
if (!result) return;
const target: DropTarget | null = calcDropTarget(point, model, element);
if (!target) return;
const index =
parent.children.indexOf(model) + (result.type === 'before' ? 0 : 1);
parent.children.indexOf(model) + (target.placement === 'before' ? 0 : 1);
if (matchFlavours(parent, ['affine:note'])) {
const snapshot = this._deserializeSnapshot(state);