snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
38
blocksuite/affine/gfx/pointer/package.json
Normal file
38
blocksuite/affine/gfx/pointer/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-gfx-pointer",
|
||||
"description": "Gfx pointer for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lit": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
8
blocksuite/affine/gfx/pointer/src/effects.ts
Normal file
8
blocksuite/affine/gfx/pointer/src/effects.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { EdgelessDefaultToolButton } from './quick-tool/default-tool-button';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'edgeless-default-tool-button',
|
||||
EdgelessDefaultToolButton
|
||||
);
|
||||
}
|
||||
1
blocksuite/affine/gfx/pointer/src/index.ts
Normal file
1
blocksuite/affine/gfx/pointer/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tools';
|
||||
@@ -0,0 +1,105 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { HandIcon, SelectIcon } from '@blocksuite/icons/lit';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
import { PanTool } from '../tools';
|
||||
|
||||
export class EdgelessDefaultToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
.current-icon {
|
||||
transition: 100ms;
|
||||
}
|
||||
.current-icon > svg {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
override type = [DefaultTool, PanTool];
|
||||
|
||||
private _changeTool() {
|
||||
if (this.toolbar.activePopper) {
|
||||
// click manually always closes the popper
|
||||
this.toolbar.activePopper.dispose();
|
||||
}
|
||||
const type = this.edgelessTool?.toolType?.toolName;
|
||||
if (type !== 'default' && type !== 'pan') {
|
||||
if (localStorage.defaultTool === 'default') {
|
||||
this.setEdgelessTool(DefaultTool);
|
||||
} else if (localStorage.defaultTool === 'pan') {
|
||||
this.setEdgelessTool(PanTool, { panning: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._fadeOut();
|
||||
// wait for animation to finish
|
||||
setTimeout(() => {
|
||||
if (type === 'default') {
|
||||
this.setEdgelessTool(PanTool, { panning: false });
|
||||
} else if (type === 'pan') {
|
||||
this.setEdgelessTool(DefaultTool);
|
||||
}
|
||||
this._fadeIn();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private _fadeIn() {
|
||||
this.currentIcon.style.opacity = '1';
|
||||
this.currentIcon.style.transform = `translateY(0px)`;
|
||||
}
|
||||
|
||||
private _fadeOut() {
|
||||
this.currentIcon.style.opacity = '0';
|
||||
this.currentIcon.style.transform = `translateY(-5px)`;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!localStorage.defaultTool) {
|
||||
localStorage.defaultTool = 'default';
|
||||
}
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const tool = this.gfx.tool.currentToolName$.value;
|
||||
if (tool === 'default' || tool === 'pan') {
|
||||
localStorage.defaultTool = tool;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.toolType?.toolName;
|
||||
const { active } = this;
|
||||
const tipInfo =
|
||||
type === 'pan'
|
||||
? { tip: 'Hand', shortcut: 'H' }
|
||||
: { tip: 'Select', shortcut: 'V' };
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-default-button ${type}"
|
||||
.tooltip=${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${tipInfo.tip}"
|
||||
data-shortcut="${tipInfo.shortcut}"
|
||||
></affine-tooltip-content-with-shortcut>`}
|
||||
.tooltipOffset=${17}
|
||||
.active=${active}
|
||||
.iconContainerPadding=${6}
|
||||
.iconSize=${'24px'}
|
||||
@click=${this._changeTool}
|
||||
>
|
||||
<div class="current-icon">
|
||||
${localStorage.defaultTool === 'default' ? SelectIcon() : HandIcon()}
|
||||
</div>
|
||||
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.current-icon')
|
||||
accessor currentIcon!: HTMLInputElement;
|
||||
}
|
||||
12
blocksuite/affine/gfx/pointer/src/quick-tool/quick-tool.ts
Normal file
12
blocksuite/affine/gfx/pointer/src/quick-tool/quick-tool.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { html } from 'lit';
|
||||
|
||||
export const defaultQuickTool = QuickToolExtension('default', ({ block }) => {
|
||||
return {
|
||||
priority: 100,
|
||||
type: 'default',
|
||||
content: html`<edgeless-default-tool-button
|
||||
.edgeless=${block}
|
||||
></edgeless-default-tool-button>`,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
160
blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts
Normal file
160
blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { OverlayIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { MindmapElementModel } from '@blocksuite/affine-model';
|
||||
import { type Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
type DragExtensionInitializeContext,
|
||||
type ExtensionDragMoveContext,
|
||||
type GfxModel,
|
||||
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')
|
||||
) as SnapOverlay;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
this.action.onDragInitialize(
|
||||
(initContext: DragExtensionInitializeContext) => {
|
||||
const snapOverlay = this.snapOverlay;
|
||||
|
||||
if (!snapOverlay) {
|
||||
return {};
|
||||
}
|
||||
|
||||
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() {
|
||||
alignBound = snapOverlay.setMovingElements(
|
||||
initContext.elements,
|
||||
initContext.elements.reduce((pre, elem) => {
|
||||
if (elem.group instanceof MindmapElementModel) {
|
||||
pre.push(elem.group);
|
||||
}
|
||||
|
||||
return pre;
|
||||
}, [] as GfxModel[])
|
||||
);
|
||||
alignStride.reset();
|
||||
},
|
||||
onDragMove(context: ExtensionDragMoveContext) {
|
||||
if (
|
||||
context.elements.length === 0 ||
|
||||
!alignBound ||
|
||||
alignBound.w === 0 ||
|
||||
alignBound.h === 0
|
||||
) {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.action.onElementResize(() => {
|
||||
const snapOverlay = this.snapOverlay;
|
||||
|
||||
if (!snapOverlay) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
onResizeStart(context) {
|
||||
snapOverlay.setMovingElements(context.elements);
|
||||
},
|
||||
onResizeMove(context) {
|
||||
const {
|
||||
handle,
|
||||
originalBound,
|
||||
scaleX,
|
||||
scaleY,
|
||||
handleSign,
|
||||
currentHandlePos,
|
||||
elements,
|
||||
} = context;
|
||||
const rotate = elements.length > 1 ? 0 : elements[0].rotate;
|
||||
const alignDirection: ('vertical' | 'horizontal')[] = [];
|
||||
let switchDirection = false;
|
||||
let nx = handleSign.x;
|
||||
let ny = handleSign.y;
|
||||
|
||||
if (handle.length > 6) {
|
||||
alignDirection.push('vertical', 'horizontal');
|
||||
} else if (rotate % 90 === 0) {
|
||||
nx =
|
||||
handleSign.x * Math.cos((rotate / 180) * Math.PI) -
|
||||
handleSign.y * Math.sin((rotate / 180) * Math.PI);
|
||||
ny =
|
||||
handleSign.x * Math.sin((rotate / 180) * Math.PI) +
|
||||
handleSign.y * Math.cos((rotate / 180) * Math.PI);
|
||||
|
||||
if (Math.abs(nx) > Math.abs(ny)) {
|
||||
alignDirection.push('horizontal');
|
||||
} else {
|
||||
alignDirection.push('vertical');
|
||||
}
|
||||
|
||||
if (rotate % 180 !== 0) {
|
||||
switchDirection = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (alignDirection.length > 0) {
|
||||
const rst = snapOverlay.alignResize(
|
||||
currentHandlePos,
|
||||
alignDirection
|
||||
);
|
||||
|
||||
const dx = switchDirection ? ny * rst.dy : nx * rst.dx;
|
||||
const dy = switchDirection ? nx * rst.dx : ny * rst.dy;
|
||||
|
||||
context.suggest({
|
||||
scaleX: scaleX + dx / originalBound.w,
|
||||
scaleY: scaleY + dy / originalBound.h,
|
||||
});
|
||||
}
|
||||
},
|
||||
onResizeEnd() {
|
||||
snapOverlay.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
840
blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts
Normal file
840
blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts
Normal file
@@ -0,0 +1,840 @@
|
||||
import { Overlay } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
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?: {
|
||||
/**
|
||||
* the minimum x moving distance to align with other bound
|
||||
*/
|
||||
distance: number;
|
||||
|
||||
/**
|
||||
* the indices of the align position
|
||||
*/
|
||||
alignPositionIndices: number[];
|
||||
};
|
||||
|
||||
vert?: {
|
||||
/**
|
||||
* the minimum y moving distance to align with other bound
|
||||
*/
|
||||
distance: number;
|
||||
|
||||
/**
|
||||
* the indices of the align position
|
||||
*/
|
||||
alignPositionIndices: number[];
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
private _skippedElements: Set<GfxModel> = new Set();
|
||||
|
||||
private _referenceBounds: {
|
||||
vertical: Bound[];
|
||||
horizontal: Bound[];
|
||||
all: Bound[];
|
||||
} = {
|
||||
vertical: [],
|
||||
horizontal: [],
|
||||
all: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* This variable contains reference lines that are
|
||||
* generated by the 'Distribute Alignment' function. This alignment is achieved
|
||||
* by evenly distributing elements based on specified alignment rules.
|
||||
* These lines serve as a guide for achieving equal spacing or distribution
|
||||
* among multiple graphics or design elements.
|
||||
*/
|
||||
private _distributedAlignLines: [Point, Point][] = [];
|
||||
|
||||
/**
|
||||
* This variable holds reference lines that are calculated
|
||||
* based on the self-alignment of the graphics. This alignment is determined
|
||||
* according to various aspects of the graphic itself, such as the center, edges,
|
||||
* corners, etc. It essentially represents the guidelines for the positioning
|
||||
* and alignment within the individual graphic elements.
|
||||
*/
|
||||
private _intraGraphicAlignLines: {
|
||||
horizontal: [Point, Point][];
|
||||
vertical: [Point, Point][];
|
||||
} = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
|
||||
private readonly _distributeCooldown = new AdaptiveCooldownController({
|
||||
cooldownFrames: DISTRIBUTE_ALIGN_COOLDOWN_FRAMES,
|
||||
maxCostMs: DISTRIBUTE_ALIGN_MAX_COST_MS,
|
||||
});
|
||||
|
||||
override clear() {
|
||||
this._referenceBounds = {
|
||||
vertical: [],
|
||||
horizontal: [],
|
||||
all: [],
|
||||
};
|
||||
this._intraGraphicAlignLines = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
this._distributedAlignLines = [];
|
||||
this._skippedElements.clear();
|
||||
this._distributeCooldown.reset();
|
||||
|
||||
super.clear();
|
||||
}
|
||||
|
||||
private _alignDistributeHorizontally(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
threshold: number,
|
||||
viewport: { zoom: number }
|
||||
) {
|
||||
const wBoxes: Bound[] = [];
|
||||
this._referenceBounds.horizontal.forEach(box => {
|
||||
if (box.isHorizontalCross(bound)) {
|
||||
wBoxes.push(box);
|
||||
}
|
||||
});
|
||||
|
||||
wBoxes.sort((a, b) => a.center[0] - b.center[0]);
|
||||
|
||||
let dif = Infinity;
|
||||
let min = Infinity;
|
||||
let aveDis = Number.MAX_SAFE_INTEGER;
|
||||
let curBound!: {
|
||||
leftIdx: number;
|
||||
rightIdx: number;
|
||||
spacing: number;
|
||||
points: [Point, Point][];
|
||||
};
|
||||
for (let i = 0; i < wBoxes.length; i++) {
|
||||
for (let j = i + 1; j < wBoxes.length; j++) {
|
||||
let lb = wBoxes[i],
|
||||
rb = wBoxes[j];
|
||||
// it means these bound need to be horizontally across
|
||||
if (!lb.isHorizontalCross(rb) || lb.isIntersectWithBound(rb)) continue;
|
||||
|
||||
let switchFlag = false;
|
||||
// exchange lb and rb to make sure lb is on the left of rb
|
||||
if (rb.maxX < lb.minX) {
|
||||
const temp = rb;
|
||||
rb = lb;
|
||||
lb = temp;
|
||||
switchFlag = true;
|
||||
}
|
||||
|
||||
let _centerX = 0;
|
||||
const updateDif = () => {
|
||||
dif = Math.abs(bound.center[0] - _centerX);
|
||||
const curAveDis =
|
||||
(Math.abs(lb.center[0] - bound.center[0]) +
|
||||
Math.abs(rb.center[0] - bound.center[0])) /
|
||||
2;
|
||||
if (
|
||||
dif <= threshold &&
|
||||
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
|
||||
) {
|
||||
min = dif;
|
||||
aveDis = curAveDis;
|
||||
rst.dx = _centerX - bound.center[0];
|
||||
/**
|
||||
* calculate points to draw
|
||||
*/
|
||||
const ys = [lb.minY, lb.maxY, rb.minY, rb.maxY].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const y = (ys[1] + ys[2]) / 2;
|
||||
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
|
||||
const xs = [
|
||||
_centerX - bound.w / 2,
|
||||
_centerX + bound.w / 2,
|
||||
rb.minX,
|
||||
rb.maxX,
|
||||
lb.minX,
|
||||
lb.maxX,
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
curBound = {
|
||||
leftIdx: switchFlag ? j : i,
|
||||
rightIdx: switchFlag ? i : j,
|
||||
spacing: xs[2] - xs[1],
|
||||
points: [
|
||||
[new Point(xs[1] + offset, y), new Point(xs[2] - offset, y)],
|
||||
[new Point(xs[3] + offset, y), new Point(xs[4] - offset, y)],
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* align between left and right bound
|
||||
*/
|
||||
if (lb.horizontalDistance(rb) > bound.w) {
|
||||
_centerX = (lb.maxX + rb.minX) / 2;
|
||||
updateDif();
|
||||
}
|
||||
|
||||
/**
|
||||
* align to the left bounds
|
||||
*/
|
||||
_centerX = lb.minX - (rb.minX - lb.maxX) - bound.w / 2;
|
||||
updateDif();
|
||||
|
||||
/** align right */
|
||||
_centerX = rb.minX - lb.maxX + rb.maxX + bound.w / 2;
|
||||
updateDif();
|
||||
}
|
||||
}
|
||||
|
||||
// find the boxes that has same spacing
|
||||
if (curBound) {
|
||||
const { leftIdx, rightIdx, spacing, points } = curBound;
|
||||
|
||||
this._distributedAlignLines.push(...points);
|
||||
|
||||
{
|
||||
let curLeftBound = wBoxes[leftIdx];
|
||||
|
||||
for (let i = leftIdx - 1; i >= 0; i--) {
|
||||
if (almostEqual(wBoxes[i].maxX, curLeftBound.minX - spacing)) {
|
||||
const targetBound = wBoxes[i];
|
||||
const ys = [
|
||||
targetBound.minY,
|
||||
targetBound.maxY,
|
||||
curLeftBound.minY,
|
||||
curLeftBound.maxY,
|
||||
].sort((a, b) => a - b);
|
||||
const y = (ys[1] + ys[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(wBoxes[i].maxX, y),
|
||||
new Point(curLeftBound.minX, y),
|
||||
]);
|
||||
|
||||
curLeftBound = wBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let curRightBound = wBoxes[rightIdx];
|
||||
|
||||
for (let i = rightIdx + 1; i < wBoxes.length; i++) {
|
||||
if (almostEqual(wBoxes[i].minX, curRightBound.maxX + spacing)) {
|
||||
const targetBound = wBoxes[i];
|
||||
const ys = [
|
||||
targetBound.minY,
|
||||
targetBound.maxY,
|
||||
curRightBound.minY,
|
||||
curRightBound.maxY,
|
||||
].sort((a, b) => a - b);
|
||||
const y = (ys[1] + ys[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(curRightBound.maxX, y),
|
||||
new Point(wBoxes[i].minX, y),
|
||||
]);
|
||||
|
||||
curRightBound = wBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _alignDistributeVertically(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
threshold: number,
|
||||
viewport: { zoom: number }
|
||||
) {
|
||||
const hBoxes: Bound[] = [];
|
||||
this._referenceBounds.vertical.forEach(box => {
|
||||
if (box.isVerticalCross(bound)) {
|
||||
hBoxes.push(box);
|
||||
}
|
||||
});
|
||||
|
||||
hBoxes.sort((a, b) => a.center[0] - b.center[0]);
|
||||
|
||||
let dif = Infinity;
|
||||
let min = Infinity;
|
||||
let aveDis = Number.MAX_SAFE_INTEGER;
|
||||
let curBound!: {
|
||||
upperIdx: number;
|
||||
lowerIdx: number;
|
||||
spacing: number;
|
||||
points: [Point, Point][];
|
||||
};
|
||||
for (let i = 0; i < hBoxes.length; i++) {
|
||||
for (let j = i + 1; j < hBoxes.length; j++) {
|
||||
let ub = hBoxes[i],
|
||||
db = hBoxes[j];
|
||||
if (!ub.isVerticalCross(db) || ub.isIntersectWithBound(db)) continue;
|
||||
|
||||
let switchFlag = false;
|
||||
if (db.maxY < ub.minX) {
|
||||
const temp = ub;
|
||||
ub = db;
|
||||
db = temp;
|
||||
switchFlag = true;
|
||||
}
|
||||
|
||||
/** align middle */
|
||||
let _centerY = 0;
|
||||
const updateDiff = () => {
|
||||
dif = Math.abs(bound.center[1] - _centerY);
|
||||
const curAveDis =
|
||||
(Math.abs(ub.center[1] - bound.center[1]) +
|
||||
Math.abs(db.center[1] - bound.center[1])) /
|
||||
2;
|
||||
|
||||
if (
|
||||
dif <= threshold &&
|
||||
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
|
||||
) {
|
||||
min = dif;
|
||||
rst.dy = _centerY - bound.center[1];
|
||||
/**
|
||||
* calculate points to draw
|
||||
*/
|
||||
const xs = [ub.minX, ub.maxX, db.minX, db.maxX].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const x = (xs[1] + xs[2]) / 2;
|
||||
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
|
||||
const ys = [
|
||||
_centerY - bound.h / 2,
|
||||
_centerY + bound.h / 2,
|
||||
db.minY,
|
||||
db.maxY,
|
||||
ub.minY,
|
||||
ub.maxY,
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
curBound = {
|
||||
upperIdx: switchFlag ? j : i,
|
||||
lowerIdx: switchFlag ? i : j,
|
||||
spacing: ys[2] - ys[1],
|
||||
points: [
|
||||
[new Point(x, ys[1] + offset), new Point(x, ys[2] - offset)],
|
||||
[new Point(x, ys[3] + offset), new Point(x, ys[4] - offset)],
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (ub.verticalDistance(db) > bound.h) {
|
||||
_centerY = (ub.maxY + db.minY) / 2;
|
||||
updateDiff();
|
||||
}
|
||||
|
||||
/** align upper */
|
||||
_centerY = ub.minY - (db.minY - ub.maxY) - bound.h / 2;
|
||||
updateDiff();
|
||||
/** align lower */
|
||||
_centerY = db.minY - ub.maxY + db.maxY + bound.h / 2;
|
||||
updateDiff();
|
||||
}
|
||||
}
|
||||
|
||||
// find the boxes that has same spacing
|
||||
if (curBound) {
|
||||
const { upperIdx, lowerIdx, spacing, points } = curBound;
|
||||
|
||||
this._distributedAlignLines.push(...points);
|
||||
|
||||
{
|
||||
let curUpperBound = hBoxes[upperIdx];
|
||||
|
||||
for (let i = upperIdx - 1; i >= 0; i--) {
|
||||
if (almostEqual(hBoxes[i].maxY, curUpperBound.minY - spacing)) {
|
||||
const targetBound = hBoxes[i];
|
||||
const xs = [
|
||||
targetBound.minX,
|
||||
targetBound.maxX,
|
||||
curUpperBound.minX,
|
||||
curUpperBound.maxX,
|
||||
].sort((a, b) => a - b);
|
||||
const x = (xs[1] + xs[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(x, hBoxes[i].maxY),
|
||||
new Point(x, curUpperBound.minY),
|
||||
]);
|
||||
|
||||
curUpperBound = hBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let curLowerBound = hBoxes[lowerIdx];
|
||||
|
||||
for (let i = lowerIdx + 1; i < hBoxes.length; i++) {
|
||||
if (almostEqual(hBoxes[i].minY, curLowerBound.maxY + spacing)) {
|
||||
const targetBound = hBoxes[i];
|
||||
const xs = [
|
||||
targetBound.minX,
|
||||
targetBound.maxX,
|
||||
curLowerBound.minX,
|
||||
curLowerBound.maxX,
|
||||
].sort((a, b) => a - b);
|
||||
const x = (xs[1] + xs[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(x, curLowerBound.maxY),
|
||||
new Point(x, hBoxes[i].minY),
|
||||
]);
|
||||
|
||||
curLowerBound = hBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateClosestDistances(bound: Bound, other: Bound): Distance {
|
||||
// Calculate center-to-center and center-to-side distances
|
||||
const centerXDistance = other.center[0] - bound.center[0];
|
||||
const centerYDistance = other.center[1] - bound.center[1];
|
||||
|
||||
// Calculate center-to-side distances
|
||||
const leftDistance = other.minX - bound.center[0];
|
||||
const rightDistance = other.maxX - bound.center[0];
|
||||
const topDistance = other.minY - bound.center[1];
|
||||
const bottomDistance = other.maxY - bound.center[1];
|
||||
|
||||
// Calculate side-to-side distances
|
||||
const leftToLeft = other.minX - bound.minX;
|
||||
const leftToRight = other.maxX - bound.minX;
|
||||
const rightToLeft = other.minX - bound.maxX;
|
||||
const rightToRight = other.maxX - bound.maxX;
|
||||
|
||||
const topToTop = other.minY - bound.minY;
|
||||
const topToBottom = other.maxY - bound.minY;
|
||||
const bottomToTop = other.minY - bound.maxY;
|
||||
const bottomToBottom = other.maxY - bound.maxY;
|
||||
|
||||
// calculate side-to-center distances
|
||||
const rightToCenter = other.center[0] - bound.maxX;
|
||||
const leftToCenter = other.center[0] - bound.minX;
|
||||
const topToCenter = other.center[1] - bound.minY;
|
||||
const bottomToCenter = other.center[1] - bound.maxY;
|
||||
|
||||
const xDistances = [
|
||||
centerXDistance,
|
||||
leftDistance,
|
||||
rightDistance,
|
||||
leftToLeft,
|
||||
leftToRight,
|
||||
rightToLeft,
|
||||
rightToRight,
|
||||
rightToCenter,
|
||||
leftToCenter,
|
||||
];
|
||||
|
||||
const yDistances = [
|
||||
centerYDistance,
|
||||
topDistance,
|
||||
bottomDistance,
|
||||
topToTop,
|
||||
topToBottom,
|
||||
bottomToTop,
|
||||
bottomToBottom,
|
||||
topToCenter,
|
||||
bottomToCenter,
|
||||
];
|
||||
|
||||
// Get absolute distances
|
||||
const xDistancesAbs = xDistances.map(Math.abs);
|
||||
const yDistancesAbs = yDistances.map(Math.abs);
|
||||
|
||||
// Get closest distances
|
||||
const closestX = Math.min(...xDistancesAbs);
|
||||
const closestY = Math.min(...yDistancesAbs);
|
||||
|
||||
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
|
||||
|
||||
// the x and y distances will be useful for locating the align point
|
||||
return {
|
||||
horiz:
|
||||
closestX <= threshold
|
||||
? {
|
||||
distance: xDistances[xDistancesAbs.indexOf(closestX)],
|
||||
get alignPositionIndices() {
|
||||
const indices: number[] = [];
|
||||
xDistancesAbs.forEach(
|
||||
(val, idx) => almostEqual(val, closestX) && indices.push(idx)
|
||||
);
|
||||
return indices;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
vert:
|
||||
closestY <= threshold
|
||||
? {
|
||||
distance: yDistances[yDistancesAbs.indexOf(closestY)],
|
||||
get alignPositionIndices() {
|
||||
const indices: number[] = [];
|
||||
yDistancesAbs.forEach(
|
||||
(val, idx) => almostEqual(val, closestY) && indices.push(idx)
|
||||
);
|
||||
return indices;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update horizontal moving distance `rst.dx` to align with other bound.
|
||||
* Also, update the align points to draw.
|
||||
* @param rst
|
||||
* @param bound
|
||||
* @param other
|
||||
* @param distance
|
||||
*/
|
||||
private _updateXAlignPoint(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
other: Bound,
|
||||
distance: Distance
|
||||
) {
|
||||
if (!distance.horiz) return;
|
||||
|
||||
const { distance: dx, alignPositionIndices: distanceIndices } =
|
||||
distance.horiz;
|
||||
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
|
||||
const alignXPosition = [
|
||||
other.center[0],
|
||||
other.minX + offset,
|
||||
other.maxX - offset,
|
||||
bound.minX + dx + offset,
|
||||
bound.minX + dx + offset,
|
||||
bound.maxX + dx - offset,
|
||||
bound.maxX + dx - offset,
|
||||
other.center[0] - offset,
|
||||
other.center[0] + offset,
|
||||
];
|
||||
|
||||
rst.dx = dx;
|
||||
|
||||
const dy = distance.vert?.distance ?? 0;
|
||||
const top = Math.min(bound.minY + dy, other.minY);
|
||||
const down = Math.max(bound.maxY + dy, other.maxY);
|
||||
|
||||
this._intraGraphicAlignLines.horizontal = distanceIndices.map(
|
||||
idx =>
|
||||
[
|
||||
new Point(alignXPosition[idx], top),
|
||||
new Point(alignXPosition[idx], down),
|
||||
] as [Point, Point]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update vertical moving distance `rst.dy` to align with other bound.
|
||||
* Also, update the align points to draw.
|
||||
* @param rst
|
||||
* @param bound
|
||||
* @param other
|
||||
* @param distance
|
||||
*/
|
||||
private _updateYAlignPoint(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
other: Bound,
|
||||
distance: Distance
|
||||
) {
|
||||
if (!distance.vert) return;
|
||||
|
||||
const { distance: dy, alignPositionIndices } = distance.vert;
|
||||
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
|
||||
const alignXPosition = [
|
||||
other.center[1] - offset,
|
||||
other.minY + offset,
|
||||
other.maxY - offset,
|
||||
bound.minY + dy + offset,
|
||||
bound.minY + dy + offset,
|
||||
bound.maxY + dy - offset,
|
||||
bound.maxY + dy - offset,
|
||||
other.center[1] + offset,
|
||||
other.center[1] - offset,
|
||||
];
|
||||
|
||||
rst.dy = dy;
|
||||
|
||||
const dx = distance.horiz?.distance ?? 0;
|
||||
const left = Math.min(bound.minX + dx, other.minX);
|
||||
const right = Math.max(bound.maxX + dx, other.maxX);
|
||||
|
||||
this._intraGraphicAlignLines.vertical = alignPositionIndices.map(
|
||||
idx =>
|
||||
[
|
||||
new Point(left, alignXPosition[idx]),
|
||||
new Point(right, alignXPosition[idx]),
|
||||
] as [Point, Point]
|
||||
);
|
||||
}
|
||||
|
||||
alignResize(position: IVec, direction: ('vertical' | 'horizontal')[]) {
|
||||
const rst = { dx: 0, dy: 0 };
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
const threshold = ALIGN_THRESHOLD / viewport.zoom;
|
||||
const searchBound = new Bound(
|
||||
position[0] - threshold / 2,
|
||||
position[1] - threshold / 2,
|
||||
threshold,
|
||||
threshold
|
||||
);
|
||||
const alignBound = new Bound(position[0], position[1], 0, 0);
|
||||
|
||||
this._intraGraphicAlignLines = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
this._distributedAlignLines = [];
|
||||
this._updateAlignCandidates(searchBound);
|
||||
|
||||
for (const other of this._referenceBounds.all) {
|
||||
const closestDistances = this._calculateClosestDistances(
|
||||
alignBound,
|
||||
other
|
||||
);
|
||||
|
||||
if (
|
||||
direction.includes('horizontal') &&
|
||||
closestDistances.horiz &&
|
||||
(!this._intraGraphicAlignLines.horizontal.length ||
|
||||
Math.abs(closestDistances.horiz.distance) < Math.abs(rst.dx))
|
||||
) {
|
||||
this._updateXAlignPoint(rst, alignBound, other, closestDistances);
|
||||
}
|
||||
|
||||
if (
|
||||
direction.includes('vertical') &&
|
||||
closestDistances.vert &&
|
||||
(!this._intraGraphicAlignLines.vertical.length ||
|
||||
Math.abs(closestDistances.vert.distance) < Math.abs(rst.dy))
|
||||
) {
|
||||
this._updateYAlignPoint(rst, alignBound, other, closestDistances);
|
||||
}
|
||||
}
|
||||
|
||||
this._intraGraphicAlignLines.horizontal =
|
||||
this._intraGraphicAlignLines.horizontal.slice(0, 1);
|
||||
this._intraGraphicAlignLines.vertical =
|
||||
this._intraGraphicAlignLines.vertical.slice(0, 1);
|
||||
this._renderer?.refresh();
|
||||
|
||||
return rst;
|
||||
}
|
||||
|
||||
align(bound: Bound): { dx: number; dy: number } {
|
||||
const rst = { dx: 0, dy: 0 };
|
||||
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
|
||||
this._intraGraphicAlignLines = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
this._distributedAlignLines = [];
|
||||
this._updateAlignCandidates(bound);
|
||||
|
||||
for (const other of this._referenceBounds.all) {
|
||||
const closestDistances = this._calculateClosestDistances(bound, other);
|
||||
|
||||
if (
|
||||
closestDistances.horiz &&
|
||||
(!this._intraGraphicAlignLines.horizontal.length ||
|
||||
Math.abs(closestDistances.horiz.distance) < Math.abs(rst.dx))
|
||||
) {
|
||||
this._updateXAlignPoint(rst, bound, other, closestDistances);
|
||||
}
|
||||
|
||||
if (
|
||||
closestDistances.vert &&
|
||||
(!this._intraGraphicAlignLines.vertical.length ||
|
||||
Math.abs(closestDistances.vert.distance) < Math.abs(rst.dy))
|
||||
) {
|
||||
this._updateYAlignPoint(rst, bound, other, closestDistances);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldTryDistribute =
|
||||
this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
|
||||
this._distributeCooldown.shouldRun();
|
||||
|
||||
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();
|
||||
|
||||
return rst;
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D) {
|
||||
if (
|
||||
this._intraGraphicAlignLines.vertical.length === 0 &&
|
||||
this._intraGraphicAlignLines.horizontal.length === 0 &&
|
||||
this._distributedAlignLines.length === 0
|
||||
)
|
||||
return;
|
||||
const { viewport } = this.gfx;
|
||||
const strokeWidth = STROKE_WIDTH / viewport.zoom;
|
||||
|
||||
ctx.strokeStyle = '#8B5CF6';
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.beginPath();
|
||||
|
||||
[
|
||||
...this._intraGraphicAlignLines.horizontal,
|
||||
...this._intraGraphicAlignLines.vertical,
|
||||
].forEach(line => {
|
||||
let d = '';
|
||||
if (line[0].x === line[1].x) {
|
||||
const x = line[0].x;
|
||||
const minY = Math.min(line[0].y, line[1].y);
|
||||
const maxY = Math.max(line[0].y, line[1].y);
|
||||
d = `M${x},${minY}L${x},${maxY}`;
|
||||
} else {
|
||||
const y = line[0].y;
|
||||
const minX = Math.min(line[0].x, line[1].x);
|
||||
const maxX = Math.max(line[0].x, line[1].x);
|
||||
d = `M${minX},${y}L${maxX},${y}`;
|
||||
}
|
||||
ctx.stroke(new Path2D(d));
|
||||
});
|
||||
|
||||
ctx.strokeStyle = '#CC4187';
|
||||
this._distributedAlignLines.forEach(line => {
|
||||
const bar = 10 / viewport.zoom;
|
||||
let d = '';
|
||||
if (line[0].x === line[1].x) {
|
||||
const x = line[0].x;
|
||||
const minY = Math.min(line[0].y, line[1].y);
|
||||
const maxY = Math.max(line[0].y, line[1].y);
|
||||
d = `M${x},${minY}L${x},${maxY}
|
||||
M${x - bar},${minY}L${x + bar},${minY}
|
||||
M${x - bar},${maxY}L${x + bar},${maxY} `;
|
||||
} else {
|
||||
const y = line[0].y;
|
||||
const minX = Math.min(line[0].x, line[1].x);
|
||||
const maxX = Math.max(line[0].x, line[1].x);
|
||||
d = `M${minX},${y}L${maxX},${y}
|
||||
M${minX},${y - bar}L${minX},${y + bar}
|
||||
M${maxX},${y - bar}L${maxX},${y + bar}`;
|
||||
}
|
||||
ctx.stroke(new Path2D(d));
|
||||
});
|
||||
}
|
||||
|
||||
private _isSkippedElement(element: GfxModel) {
|
||||
return (
|
||||
element instanceof ConnectorElementModel ||
|
||||
element.group instanceof MindmapElementModel
|
||||
);
|
||||
}
|
||||
|
||||
private _updateAlignCandidates(movingBound: Bound) {
|
||||
movingBound = movingBound.expand(ALIGN_THRESHOLD * this.gfx.viewport.zoom);
|
||||
|
||||
const viewportBound = this.gfx.viewport.viewportBounds;
|
||||
const horizAreaBound = new Bound(
|
||||
Math.min(movingBound.x, viewportBound.x),
|
||||
movingBound.y,
|
||||
Math.max(movingBound.w, viewportBound.w),
|
||||
movingBound.h
|
||||
);
|
||||
const vertAreaBound = new Bound(
|
||||
movingBound.x,
|
||||
Math.min(movingBound.y, viewportBound.y),
|
||||
movingBound.w,
|
||||
Math.max(movingBound.h, viewportBound.h)
|
||||
);
|
||||
|
||||
const { _skippedElements: skipped } = this;
|
||||
const vertCandidates = this.gfx.grid.search(vertAreaBound, {
|
||||
useSet: true,
|
||||
});
|
||||
const horizCandidates = this.gfx.grid.search(horizAreaBound, {
|
||||
useSet: true,
|
||||
});
|
||||
const verticalBounds: Bound[] = [];
|
||||
const horizBounds: Bound[] = [];
|
||||
const allCandidateElements = new Set<GfxModel>();
|
||||
|
||||
vertCandidates.forEach(candidate => {
|
||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||
const bound = candidate.elementBound;
|
||||
verticalBounds.push(bound);
|
||||
allCandidateElements.add(candidate);
|
||||
});
|
||||
|
||||
horizCandidates.forEach(candidate => {
|
||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||
const bound = candidate.elementBound;
|
||||
horizBounds.push(bound);
|
||||
allCandidateElements.add(candidate);
|
||||
});
|
||||
|
||||
this._referenceBounds = {
|
||||
horizontal: horizBounds,
|
||||
vertical: verticalBounds,
|
||||
all: [...allCandidateElements].map(element => element.elementBound),
|
||||
};
|
||||
}
|
||||
|
||||
setMovingElements(
|
||||
movingElements: GfxModel[],
|
||||
excludes: GfxModel[] = []
|
||||
): Bound {
|
||||
if (movingElements.length === 0) return new Bound();
|
||||
|
||||
const skipped = new Set(movingElements);
|
||||
excludes.forEach(e => skipped.add(e));
|
||||
|
||||
this._skippedElements = skipped;
|
||||
|
||||
return movingElements.reduce(
|
||||
(prev, element) => prev.unite(element.elementBound),
|
||||
movingElements[0].elementBound
|
||||
);
|
||||
}
|
||||
}
|
||||
8
blocksuite/affine/gfx/pointer/src/tools/empty-tool.ts
Normal file
8
blocksuite/affine/gfx/pointer/src/tools/empty-tool.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { BaseTool } from '@blocksuite/std/gfx';
|
||||
|
||||
/**
|
||||
* Empty tool that does nothing.
|
||||
*/
|
||||
export class EmptyTool extends BaseTool {
|
||||
static override toolName: string = 'empty';
|
||||
}
|
||||
2
blocksuite/affine/gfx/pointer/src/tools/index.ts
Normal file
2
blocksuite/affine/gfx/pointer/src/tools/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './empty-tool.js';
|
||||
export * from './pan-tool.js';
|
||||
151
blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts
Normal file
151
blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
30
blocksuite/affine/gfx/pointer/src/view.ts
Normal file
30
blocksuite/affine/gfx/pointer/src/view.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import { defaultQuickTool } from './quick-tool/quick-tool';
|
||||
import { SnapExtension } from './snap/snap-manager';
|
||||
import { SnapOverlay } from './snap/snap-overlay';
|
||||
import { EmptyTool, PanTool } from './tools';
|
||||
|
||||
export class PointerViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-pointer-gfx';
|
||||
|
||||
override effect() {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(EmptyTool);
|
||||
context.register(PanTool);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(defaultQuickTool);
|
||||
context.register(SnapExtension);
|
||||
context.register(SnapOverlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
blocksuite/affine/gfx/pointer/tsconfig.json
Normal file
18
blocksuite/affine/gfx/pointer/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/edgeless-toolbar" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" }
|
||||
]
|
||||
}
|
||||
29
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal file
29
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
target: 'es2018',
|
||||
},
|
||||
test: {
|
||||
globalSetup: fileURLToPath(
|
||||
new URL('../../../../scripts/vitest-global.js', import.meta.url)
|
||||
),
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user