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:
DarkSky
2026-02-15 03:17:22 +08:00
committed by GitHub
parent c0694c589b
commit 25227a09f7
33 changed files with 2169 additions and 159 deletions

View File

@@ -84,6 +84,8 @@ export const connectorWatcher: SurfaceMiddleware = (
);
return () => {
pendingFlag = false;
pendingList.clear();
disposables.forEach(d => d.unsubscribe());
};
};

View File

@@ -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",

View File

@@ -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<TestElement, number>([
[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');
});
});

View File

@@ -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<GfxModel[]>(
(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();
};

View File

@@ -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',
},
});

View File

@@ -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"

View File

@@ -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);
});
});

View File

@@ -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<string, unknown>;
}) => {
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<Record<'pointerDown', PointerDownHandler>> = {};
(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,
}
);
});
});

View File

@@ -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;
}
}

View File

@@ -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();
},
};

View File

@@ -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<GfxModel>();
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),
};
}

View File

@@ -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<PanToolOption> {
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;
}
@@ -43,12 +65,14 @@ export class PanTool extends BaseTool<PanToolOption> {
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<PanToolOption> {
return false;
});
}
override unmounted(): void {
this._deltaFlushCoalescer.cancel();
}
}

View File

@@ -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',
},
});