Files
AFFiNE-Mirror/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts
DarkSky 25227a09f7 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 -->
2026-02-15 03:17:22 +08:00

152 lines
4.2 KiB
TypeScript

import {
DefaultTool,
EdgelessLegacySlotIdentifier,
} from '@blocksuite/affine-block-surface';
import { on } from '@blocksuite/affine-shared/utils';
import type { PointerEventState } from '@blocksuite/std';
import {
BaseTool,
createRafCoalescer,
MouseButton,
type ToolOptions,
} from '@blocksuite/std/gfx';
import { Signal } from '@preact/signals-core';
interface RestorablePresentToolOptions {
mode?: string; // 'fit' | 'fill', simplified to string for local use
restoredAfterPan?: boolean;
}
export type PanToolOption = {
panning: boolean;
};
export class PanTool extends BaseTool<PanToolOption> {
static override toolName = 'pan';
private _lastPoint: [number, number] | null = null;
private _pendingDelta: [number, number] = [0, 0];
private readonly _deltaFlushCoalescer = createRafCoalescer<void>(() => {
this._flushPendingDelta();
});
readonly panning$ = new Signal<boolean>(false);
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;
}
override dragMove(e: PointerEventState): void {
if (!this._lastPoint) return;
const { viewport } = this.gfx;
const { zoom } = viewport;
const [lastX, lastY] = this._lastPoint;
const deltaX = lastX - e.x;
const deltaY = lastY - e.y;
this._lastPoint = [e.x, e.y];
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;
}
override mounted(): void {
this.addHook('pointerDown', evt => {
const shouldPanWithMiddle = evt.raw.button === MouseButton.MIDDLE;
if (!shouldPanWithMiddle) {
return;
}
const currentTool = this.controller.currentToolOption$.peek();
const { toolType, options: originalToolOptions } = currentTool;
if (toolType?.toolName === PanTool.toolName) {
return;
}
evt.raw.preventDefault();
const selectionToRestore = this.gfx.selection.surfaceSelections.slice();
const restoreToPrevious = () => {
this.gfx.selection.set(selectionToRestore);
if (!toolType) return;
// restore to DefaultTool if previous tool is CopilotTool
if (toolType.toolName === 'copilot') {
this.controller.setTool(DefaultTool);
return;
}
let finalOptions: ToolOptions<BaseTool<any>> | undefined =
originalToolOptions;
if (toolType.toolName === 'frameNavigator') {
// When restoring PresentTool (frameNavigator) after a temporary pan (e.g., via middle mouse button),
// set 'restoredAfterPan' to true. This allows PresentTool to avoid an unwanted viewport reset
// and maintain the panned position.
const currentPresentOptions = originalToolOptions as
| RestorablePresentToolOptions
| undefined;
finalOptions = {
...currentPresentOptions,
restoredAfterPan: true,
} as RestorablePresentToolOptions;
}
this.controller.setTool(toolType, finalOptions);
};
// If in presentation mode, disable black background after middle mouse drag
if (toolType?.toolName === 'frameNavigator') {
const slots = this.std.get(EdgelessLegacySlotIdentifier);
slots.navigatorSettingUpdated.next({
blackBackground: false,
});
}
this.controller.setTool(PanTool, {
panning: true,
});
const dispose = on(document, 'pointerup', evt => {
if (evt.button === MouseButton.MIDDLE) {
restoreToPrevious();
}
dispose();
});
return false;
});
}
override unmounted(): void {
this._deltaFlushCoalescer.cancel();
}
}