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:
53
blocksuite/framework/std/package.json
Normal file
53
blocksuite/framework/std/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@blocksuite/std",
|
||||
"description": "Std for blocksuite blocks",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^2.0.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"dompurify": "^3.4.11",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"lib0": "^0.2.114",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lz-string": "^1.5.0",
|
||||
"rehype-parse": "^9.0.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"unified": "^11.0.5",
|
||||
"w3c-keyname": "^2.2.8",
|
||||
"yjs": "^13.6.27",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"playwright": "=1.58.2",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./gfx": "./src/gfx/index.ts",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./inline": "./src/inline/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
414
blocksuite/framework/std/src/__tests__/command.unit.spec.ts
Normal file
414
blocksuite/framework/std/src/__tests__/command.unit.spec.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Command } from '../command/index.js';
|
||||
import { CommandManager } from '../command/index.js';
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
|
||||
type Command1 = Command<
|
||||
{
|
||||
command1Option?: string;
|
||||
},
|
||||
{
|
||||
commandData1: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type Command2 = Command<{ commandData1: string }, { commandData2: string }>;
|
||||
|
||||
describe('CommandManager', () => {
|
||||
let std: BlockStdScope;
|
||||
let commandManager: CommandManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
std = {};
|
||||
commandManager = new CommandManager(std);
|
||||
});
|
||||
|
||||
test('can add and execute a command', () => {
|
||||
const command1: Command1 = vi.fn((_ctx, next) => next());
|
||||
const command2: Command2 = vi.fn((_ctx, _next) => {});
|
||||
|
||||
const [success1] = commandManager.chain().pipe(command1, {}).run();
|
||||
const [success2] = commandManager
|
||||
.chain()
|
||||
.pipe(command1, {})
|
||||
.pipe(command2)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(success1).toBeTruthy();
|
||||
expect(success2).toBeFalsy();
|
||||
});
|
||||
|
||||
test('can chain multiple commands', () => {
|
||||
const command1: Command = vi.fn((_ctx, next) => next());
|
||||
const command2: Command = vi.fn((_ctx, next) => next());
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
|
||||
test('skip commands if there is a command failed before them (`next` not executed)', () => {
|
||||
const command1: Command = vi.fn((_ctx, next) => next());
|
||||
const command2: Command = vi.fn((_ctx, _next) => {});
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).not.toHaveBeenCalled();
|
||||
expect(success).toBeFalsy();
|
||||
});
|
||||
|
||||
test('can handle command failure', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const command1: Command = vi.fn((_ctx, next) => next());
|
||||
const command2: Command = vi.fn((_ctx, _next) => {
|
||||
throw new Error('command2 failed');
|
||||
});
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).not.toHaveBeenCalled();
|
||||
expect(success).toBeFalsy();
|
||||
expect(errorSpy).toHaveBeenCalledWith(new Error('command2 failed'));
|
||||
});
|
||||
|
||||
test('can pass data to command when calling a command', () => {
|
||||
const command1: Command1 = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1, { command1Option: 'test' })
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command1Option: 'test' }),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
|
||||
test('can add data to the command chain with `with` method', () => {
|
||||
const command1: Command<{ commandData1: string }> = vi.fn((_ctx, next) =>
|
||||
next()
|
||||
);
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.with({ commandData1: 'test' })
|
||||
.pipe(command1)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commandData1: 'test' }),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(success).toBeTruthy();
|
||||
expect(ctx.commandData1).toBe('test');
|
||||
});
|
||||
|
||||
test('passes and updates context across commands', () => {
|
||||
const command1: Command<{}, { commandData1: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData1: '123' })
|
||||
);
|
||||
const command2: Command<{ commandData1: string }> = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('123');
|
||||
next({ commandData1: '456' });
|
||||
});
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
expect(ctx.commandData1).toBe('456');
|
||||
});
|
||||
|
||||
test('should not continue with the rest of the chain if all commands in `try` fail', () => {
|
||||
const command1: Command = vi.fn((_ctx, _next) => {});
|
||||
const command2: Command = vi.fn((_ctx, _next) => {});
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.try(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).not.toHaveBeenCalled();
|
||||
expect(success).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should not re-execute previous commands in the chain before `try`', () => {
|
||||
const command1: Command<{}, { commandData1: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData1: '123' })
|
||||
);
|
||||
const command2: Command = vi.fn((_ctx, _next) => {});
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.try(chain => [chain.pipe(command2), chain.pipe(command3)])
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalledTimes(1);
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
expect(ctx.commandData1).toBe('123');
|
||||
});
|
||||
|
||||
test('should continue with the rest of the chain if one command in `try` succeeds', () => {
|
||||
const command1: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, _next) => {});
|
||||
const command2: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData2: '123' }));
|
||||
const command3: Command<{}, { commandData3: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData3: '456' })
|
||||
);
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.try(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
expect(ctx.commandData1).toBeUndefined();
|
||||
expect(ctx.commandData2).toBe('123');
|
||||
expect(ctx.commandData3).toBe('456');
|
||||
});
|
||||
|
||||
test('should not execute any further commands in `try` after one succeeds', () => {
|
||||
const command1: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData1: '123' }));
|
||||
const command2: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData2: '456' }));
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.try(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).not.toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
expect(ctx.commandData1).toBe('123');
|
||||
expect(ctx.commandData2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should pass context correctly in `try` when a command succeeds', () => {
|
||||
const command1: Command = vi.fn((_ctx, next) =>
|
||||
next({ commandData1: 'fromCommand1', commandData2: 'fromCommand1' })
|
||||
);
|
||||
const command2: Command = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand1');
|
||||
expect(ctx.commandData2).toBe('fromCommand1');
|
||||
// override commandData2
|
||||
next({ commandData2: 'fromCommand2' });
|
||||
});
|
||||
const command3: Command = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand1');
|
||||
expect(ctx.commandData2).toBe('fromCommand2');
|
||||
next();
|
||||
});
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.try(chain => [chain.pipe(command2)])
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should continue with the rest of the chain if at least one command in `tryAll` succeeds', () => {
|
||||
const command1: Command = vi.fn((_ctx, _next) => {});
|
||||
const command2: Command = vi.fn((_ctx, next) => next());
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.tryAll(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should execute all commands in `tryAll` even if one has already succeeded', () => {
|
||||
const command1: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string; commandData3?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData1: '123' }));
|
||||
const command2: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string; commandData3?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData2: '456' }));
|
||||
const command3: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string; commandData3?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData3: '789' }));
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.tryAll(chain => [
|
||||
chain.pipe(command1),
|
||||
chain.pipe(command2),
|
||||
chain.pipe(command3),
|
||||
])
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(ctx.commandData1).toBe('123');
|
||||
expect(ctx.commandData2).toBe('456');
|
||||
expect(ctx.commandData3).toBe('789');
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should not continue with the rest of the chain if all commands in `tryAll` fail', () => {
|
||||
const command1: Command = vi.fn((_ctx, _next) => {});
|
||||
const command2: Command = vi.fn((_ctx, _next) => {});
|
||||
const command3: Command<{}, { commandData3: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData3: '123' })
|
||||
);
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.tryAll(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(command3)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).not.toHaveBeenCalled();
|
||||
expect(ctx.commandData3).toBeUndefined();
|
||||
expect(success).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should pass context correctly in `tryAll` when at least one command succeeds', () => {
|
||||
const command1: Command<{}, { commandData1: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData1: 'fromCommand1' })
|
||||
);
|
||||
const command2: Command<
|
||||
{ commandData1: string; commandData2?: string },
|
||||
{ commandData2: string; commandData3: string }
|
||||
> = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand1');
|
||||
// override commandData1
|
||||
next({ commandData1: 'fromCommand2', commandData2: 'fromCommand2' });
|
||||
});
|
||||
const command3: Command<
|
||||
{ commandData1: string; commandData2?: string },
|
||||
{ commandData2: string; commandData3: string }
|
||||
> = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand2');
|
||||
expect(ctx.commandData2).toBe('fromCommand2');
|
||||
next({
|
||||
// override commandData2
|
||||
commandData2: 'fromCommand3',
|
||||
commandData3: 'fromCommand3',
|
||||
});
|
||||
});
|
||||
const command4: Command<
|
||||
{ commandData1: string; commandData2: string; commandData3: string },
|
||||
{}
|
||||
> = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand2');
|
||||
expect(ctx.commandData2).toBe('fromCommand3');
|
||||
expect(ctx.commandData3).toBe('fromCommand3');
|
||||
next();
|
||||
});
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.tryAll(chain => [chain.pipe(command2), chain.pipe(command3)])
|
||||
.pipe(command4)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(command4).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
expect(ctx.commandData1).toBe('fromCommand2');
|
||||
expect(ctx.commandData2).toBe('fromCommand3');
|
||||
expect(ctx.commandData3).toBe('fromCommand3');
|
||||
});
|
||||
|
||||
test('should not re-execute commands before `tryAll` after executing `tryAll`', () => {
|
||||
const command1: Command = vi.fn((_ctx, next) => next());
|
||||
const command2: Command = vi.fn((_ctx, next) => next());
|
||||
const command3: Command = vi.fn((_ctx, _next) => {});
|
||||
const command4: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.tryAll(chain => [chain.pipe(command2), chain.pipe(command3)])
|
||||
.pipe(command4)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalledTimes(1);
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(command4).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
createAutoIncrementIdGenerator,
|
||||
TestWorkspace,
|
||||
} from '@blocksuite/store/test';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { effects } from '../effects.js';
|
||||
import { TestEditorContainer } from './test-editor.js';
|
||||
import {
|
||||
type HeadingBlockModel,
|
||||
HeadingBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
RootBlockSchemaExtension,
|
||||
SurfaceBlockSchemaExtension,
|
||||
} from './test-schema.js';
|
||||
import { testSpecs } from './test-spec.js';
|
||||
|
||||
effects();
|
||||
|
||||
const extensions = [
|
||||
RootBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
HeadingBlockSchemaExtension,
|
||||
SurfaceBlockSchemaExtension,
|
||||
];
|
||||
|
||||
function createTestOptions() {
|
||||
const idGenerator = createAutoIncrementIdGenerator();
|
||||
return { id: 'test-collection', idGenerator };
|
||||
}
|
||||
|
||||
function wait(time: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
describe('editor host', () => {
|
||||
test('editor host should rerender model when view changes', async () => {
|
||||
const collection = new TestWorkspace(createTestOptions());
|
||||
|
||||
collection.meta.initialize();
|
||||
const doc = collection.createDoc('home');
|
||||
const store = doc.getStore({ extensions });
|
||||
doc.load();
|
||||
const rootId = store.addBlock('test:page');
|
||||
const noteId = store.addBlock('test:note', {}, rootId);
|
||||
const headingId = store.addBlock('test:heading', { type: 'h1' }, noteId);
|
||||
const headingBlock = store.getBlock(headingId)!;
|
||||
|
||||
const editorContainer = new TestEditorContainer();
|
||||
editorContainer.doc = store;
|
||||
editorContainer.specs = testSpecs;
|
||||
|
||||
document.body.append(editorContainer);
|
||||
|
||||
await wait(50);
|
||||
let headingElm = editorContainer.std.view.getBlock(headingId);
|
||||
|
||||
expect(headingElm!.tagName).toBe('TEST-H1-BLOCK');
|
||||
|
||||
(headingBlock.model as HeadingBlockModel).props.type = 'h2';
|
||||
await wait(50);
|
||||
headingElm = editorContainer.std.view.getBlock(headingId);
|
||||
|
||||
expect(headingElm!.tagName).toBe('TEST-H2-BLOCK');
|
||||
});
|
||||
});
|
||||
418
blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts
Normal file
418
blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import {
|
||||
createAutoIncrementIdGenerator,
|
||||
TestWorkspace,
|
||||
} from '@blocksuite/store/test';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { effects } from '../../effects.js';
|
||||
import type { TestShapeElement } from '../test-gfx-element.js';
|
||||
import {
|
||||
RootBlockSchemaExtension,
|
||||
type SurfaceBlockModel,
|
||||
SurfaceBlockSchemaExtension,
|
||||
} from '../test-schema.js';
|
||||
|
||||
effects();
|
||||
|
||||
const extensions = [RootBlockSchemaExtension, SurfaceBlockSchemaExtension];
|
||||
|
||||
function createTestOptions() {
|
||||
const idGenerator = createAutoIncrementIdGenerator();
|
||||
return { id: 'test-collection', idGenerator };
|
||||
}
|
||||
|
||||
const commonSetup = () => {
|
||||
const collection = new TestWorkspace(createTestOptions());
|
||||
|
||||
collection.meta.initialize();
|
||||
const doc = collection.createDoc('home');
|
||||
const store = doc.getStore({ extensions });
|
||||
doc.load();
|
||||
|
||||
const rootId = store.addBlock('test:page');
|
||||
const surfaceId = store.addBlock('test:surface', {}, rootId);
|
||||
|
||||
const surfaceBlock = store.getBlock(surfaceId)!;
|
||||
|
||||
return {
|
||||
surfaceId,
|
||||
surfaceModel: surfaceBlock.model as SurfaceBlockModel,
|
||||
};
|
||||
};
|
||||
|
||||
describe('surface basic', () => {
|
||||
test('addElement should work correctly', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
|
||||
expect(model.elementModels[0].id).toBe(id);
|
||||
});
|
||||
|
||||
test('removeElement should work correctly', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
|
||||
model.deleteElement(id);
|
||||
|
||||
expect(model.elementModels.length).toBe(0);
|
||||
});
|
||||
|
||||
test('updateElement should work correctly', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
|
||||
model.updateElement(id, { xywh: '[10,10,200,200]' });
|
||||
|
||||
expect(model.elementModels[0].xywh).toBe('[10,10,200,200]');
|
||||
});
|
||||
|
||||
test('getElementById should return element', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
|
||||
expect(model.getElementById(id)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('getElementById should return null if not found', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
|
||||
expect(model.getElementById('not-found')).toBeNull();
|
||||
});
|
||||
|
||||
test('created observer should be called', () => {
|
||||
const { surfaceModel } = commonSetup();
|
||||
|
||||
let expectPayload;
|
||||
const elementAddedCallback = vi.fn(payload => (expectPayload = payload));
|
||||
|
||||
surfaceModel.elementAdded.subscribe(elementAddedCallback);
|
||||
|
||||
const shapeId = surfaceModel.addElement({
|
||||
type: 'testShape',
|
||||
rotate: 0,
|
||||
xywh: '[0, 0, 10, 10]',
|
||||
});
|
||||
|
||||
expect(elementAddedCallback).toHaveBeenCalled();
|
||||
expect(expectPayload).toMatchObject({
|
||||
id: shapeId,
|
||||
});
|
||||
});
|
||||
|
||||
test('update and props observer should be called', () => {
|
||||
const { surfaceModel } = commonSetup();
|
||||
|
||||
const shapeId = surfaceModel.addElement({
|
||||
type: 'testShape',
|
||||
rotate: 0,
|
||||
xywh: '[0, 0, 10, 10]',
|
||||
});
|
||||
const shapeModel = surfaceModel.getElementById(shapeId)!;
|
||||
|
||||
let expectPayload;
|
||||
const elementUpdatedCallback = vi.fn(payload => (expectPayload = payload));
|
||||
let propsUpdatedPayload;
|
||||
const propsUpdatedCallback = vi.fn(payload => {
|
||||
propsUpdatedPayload = payload;
|
||||
});
|
||||
|
||||
surfaceModel.elementUpdated.subscribe(elementUpdatedCallback);
|
||||
shapeModel.propsUpdated.subscribe(propsUpdatedCallback);
|
||||
|
||||
surfaceModel.updateElement(shapeId, {
|
||||
rotate: 10,
|
||||
});
|
||||
|
||||
expect(elementUpdatedCallback).toHaveBeenCalled();
|
||||
expect(propsUpdatedCallback).toHaveBeenCalled();
|
||||
expect(expectPayload).toMatchObject({
|
||||
id: shapeId,
|
||||
props: {
|
||||
rotate: 10,
|
||||
},
|
||||
oldValues: {
|
||||
rotate: 0,
|
||||
},
|
||||
});
|
||||
expect(propsUpdatedPayload).toMatchObject({
|
||||
key: 'rotate',
|
||||
});
|
||||
});
|
||||
|
||||
test('delete observer should be called', () => {
|
||||
const { surfaceModel } = commonSetup();
|
||||
|
||||
const shapeId = surfaceModel.addElement({
|
||||
type: 'testShape',
|
||||
rotate: 0,
|
||||
xywh: '[0, 0, 10, 10]',
|
||||
});
|
||||
|
||||
let expectPayload;
|
||||
const deletedCallback = vi.fn(payload => (expectPayload = payload));
|
||||
|
||||
surfaceModel.elementRemoved.subscribe(deletedCallback);
|
||||
surfaceModel.deleteElement(shapeId);
|
||||
|
||||
expect(deletedCallback).toHaveBeenCalled();
|
||||
expect(expectPayload).toMatchObject({
|
||||
id: shapeId,
|
||||
type: 'testShape',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('element model', () => {
|
||||
test('default value should work correctly', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
|
||||
const element = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
expect(element.rotate).toBe(0);
|
||||
expect(element.xywh).toBe('[0,0,10,10]');
|
||||
});
|
||||
|
||||
test('defined prop should not be overwritten by default value', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
rotate: 20,
|
||||
});
|
||||
|
||||
const element = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
expect(element.rotate).toBe(20);
|
||||
});
|
||||
|
||||
test('assign value to model property should update ymap directly', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
|
||||
const element = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
expect(element.yMap.get('rotate')).toBe(0);
|
||||
element.rotate = 30;
|
||||
expect(element.yMap.get('rotate')).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stash/pop', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
test('stash and pop should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
expect(elementModel.rotate).toBe(0);
|
||||
|
||||
elementModel.stash('rotate');
|
||||
elementModel.rotate = 10;
|
||||
expect(elementModel.rotate).toBe(10);
|
||||
expect(elementModel.yMap.get('rotate')).toBe(0);
|
||||
|
||||
elementModel.pop('rotate');
|
||||
expect(elementModel.rotate).toBe(10);
|
||||
expect(elementModel.yMap.get('rotate')).toBe(10);
|
||||
|
||||
elementModel.rotate = 6;
|
||||
expect(elementModel.rotate).toBe(6);
|
||||
expect(elementModel.yMap.get('rotate')).toBe(6);
|
||||
});
|
||||
|
||||
test('assign stashed property should emit event', () => {
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
rotate: 4,
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
elementModel.stash('rotate');
|
||||
|
||||
const onchange = vi.fn();
|
||||
const subscription = model.elementUpdated.subscribe(({ id }) => {
|
||||
subscription.unsubscribe();
|
||||
onchange(id);
|
||||
});
|
||||
|
||||
elementModel.rotate = 10;
|
||||
expect(onchange).toHaveBeenCalledWith(id);
|
||||
});
|
||||
|
||||
test('stashed property should also trigger derive decorator', () => {
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
rotate: 20,
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
elementModel.stash('shapeType');
|
||||
elementModel.shapeType = 'triangle';
|
||||
|
||||
// rotation should be 0 'cause of derive decorator
|
||||
expect(elementModel.rotate).toBe(0);
|
||||
});
|
||||
|
||||
test('non-field property should not allow stash/pop, and should fail silently ', () => {
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
// opacity is a local property, so it should not be stashed
|
||||
elementModel.stash('opacity');
|
||||
expect(elementModel['_stashed'].has('opacity')).toBe(false);
|
||||
|
||||
// pop the `opacity` should not affect yMap
|
||||
elementModel.opacity = 0.5;
|
||||
elementModel.pop('opacity');
|
||||
expect(elementModel.yMap.has('opacity')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('derive decorator', () => {
|
||||
test('derived decorator should work correctly', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
rotate: 20,
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
elementModel.shapeType = 'triangle';
|
||||
|
||||
expect(elementModel.rotate).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local decorator', () => {
|
||||
test('local decorator should work correctly', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
expect(elementModel.display).toBe(true);
|
||||
|
||||
elementModel.display = false;
|
||||
expect(elementModel.display).toBe(false);
|
||||
|
||||
elementModel.opacity = 0.5;
|
||||
expect(elementModel.opacity).toBe(0.5);
|
||||
});
|
||||
|
||||
test('assign local property should emit event', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
const onchange = vi.fn();
|
||||
const subscription = model.elementUpdated.subscribe(({ id }) => {
|
||||
subscription.unsubscribe();
|
||||
onchange(id);
|
||||
});
|
||||
|
||||
const onPropChange = vi.fn();
|
||||
elementModel.propsUpdated.subscribe(({ key }) => {
|
||||
onPropChange(key);
|
||||
});
|
||||
|
||||
elementModel.display = false;
|
||||
|
||||
expect(elementModel.display).toBe(false);
|
||||
expect(onchange).toHaveBeenCalledWith(id);
|
||||
expect(onPropChange).toHaveBeenCalledWith('display');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convert decorator', () => {
|
||||
test('convert decorator', () => {
|
||||
const { surfaceModel: model } = commonSetup();
|
||||
const id = model.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as TestShapeElement;
|
||||
|
||||
// @ts-expect-error test needed
|
||||
elementModel.shapeType = 'otherImpossibleType';
|
||||
|
||||
expect(elementModel.shapeType).toBe('rect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('surface group index cache', () => {
|
||||
test('syncGroupChildrenIndex should replace outdated parent mappings', () => {
|
||||
const { surfaceModel } = commonSetup();
|
||||
const model = surfaceModel as any;
|
||||
|
||||
model._syncGroupChildrenIndex('group-1', ['a', 'b'], []);
|
||||
expect(model._parentGroupMap.get('a')).toBe('group-1');
|
||||
expect(model._parentGroupMap.get('b')).toBe('group-1');
|
||||
|
||||
model._syncGroupChildrenIndex('group-1', ['b', 'c']);
|
||||
expect(model._parentGroupMap.has('a')).toBe(false);
|
||||
expect(model._parentGroupMap.get('b')).toBe('group-1');
|
||||
expect(model._parentGroupMap.get('c')).toBe('group-1');
|
||||
});
|
||||
|
||||
test('removeGroupFromChildrenIndex should clear both child snapshot and reverse lookup', () => {
|
||||
const { surfaceModel } = commonSetup();
|
||||
const model = surfaceModel as any;
|
||||
|
||||
model._syncGroupChildrenIndex('group-2', ['x', 'y'], []);
|
||||
model._removeGroupFromChildrenIndex('group-2');
|
||||
|
||||
expect(model._groupChildIdsMap.has('group-2')).toBe(false);
|
||||
expect(model._parentGroupMap.has('x')).toBe(false);
|
||||
expect(model._parentGroupMap.has('y')).toBe(false);
|
||||
});
|
||||
|
||||
test('getGroup should recover from stale cache and update reverse lookup', () => {
|
||||
const { surfaceModel } = commonSetup();
|
||||
const model = surfaceModel as any;
|
||||
|
||||
const shapeId = surfaceModel.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
const shape = surfaceModel.getElementById(shapeId)!;
|
||||
|
||||
const fakeGroup = {
|
||||
id: 'group-fallback',
|
||||
hasChild: (element: { id: string }) => element.id === shapeId,
|
||||
};
|
||||
|
||||
model._groupLikeModels.set(fakeGroup.id, fakeGroup);
|
||||
model._parentGroupMap.set(shapeId, 'stale-group-id');
|
||||
|
||||
expect(surfaceModel.getGroup(shapeId)).toBe(fakeGroup);
|
||||
expect(model._parentGroupMap.get(shapeId)).toBe(fakeGroup.id);
|
||||
expect(model._parentGroupMap.has('stale-group-id')).toBe(false);
|
||||
|
||||
const otherShapeId = surfaceModel.addElement({
|
||||
type: 'testShape',
|
||||
});
|
||||
model._parentGroupMap.set(otherShapeId, 'another-missing-group');
|
||||
expect(surfaceModel.getGroup(otherShapeId)).toBeNull();
|
||||
expect(model._parentGroupMap.has(otherShapeId)).toBe(false);
|
||||
|
||||
// keep one explicit check on element-based lookup path
|
||||
expect(surfaceModel.getGroup(shape as any)).toBe(fakeGroup);
|
||||
});
|
||||
});
|
||||
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal file
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type GfxGroupCompatibleInterface,
|
||||
gfxGroupCompatibleSymbol,
|
||||
} from '../../gfx/model/base.js';
|
||||
import type { GfxModel } from '../../gfx/model/model.js';
|
||||
import {
|
||||
batchAddChildren,
|
||||
batchRemoveChildren,
|
||||
canSafeAddToContainer,
|
||||
descendantElementsImpl,
|
||||
getTopElements,
|
||||
} from '../../utils/tree.js';
|
||||
|
||||
type TestElement = {
|
||||
id: string;
|
||||
group: TestGroup | null;
|
||||
groups: TestGroup[];
|
||||
};
|
||||
|
||||
type TestGroup = TestElement & {
|
||||
[gfxGroupCompatibleSymbol]: true;
|
||||
childIds: string[];
|
||||
childElements: GfxModel[];
|
||||
addChild: (element: GfxModel) => void;
|
||||
removeChild: (element: GfxModel) => void;
|
||||
hasChild: (element: GfxModel) => boolean;
|
||||
hasDescendant: (element: GfxModel) => boolean;
|
||||
};
|
||||
|
||||
const createElement = (id: string): TestElement => ({
|
||||
id,
|
||||
group: null,
|
||||
groups: [],
|
||||
});
|
||||
|
||||
const createGroup = (id: string): TestGroup => {
|
||||
const group: TestGroup = {
|
||||
id,
|
||||
[gfxGroupCompatibleSymbol]: true,
|
||||
group: null,
|
||||
groups: [],
|
||||
childIds: [],
|
||||
childElements: [],
|
||||
addChild(element: GfxModel) {
|
||||
const child = element as unknown as TestElement;
|
||||
if (this.childElements.includes(element)) {
|
||||
return;
|
||||
}
|
||||
this.childElements.push(element);
|
||||
this.childIds.push(child.id);
|
||||
child.group = this;
|
||||
child.groups = [...this.groups, this];
|
||||
},
|
||||
removeChild(element: GfxModel) {
|
||||
const child = element as unknown as TestElement;
|
||||
this.childElements = this.childElements.filter(item => item !== element);
|
||||
this.childIds = this.childIds.filter(id => id !== child.id);
|
||||
if (child.group === this) {
|
||||
child.group = null;
|
||||
child.groups = [];
|
||||
}
|
||||
},
|
||||
hasChild(element: GfxModel) {
|
||||
return this.childElements.includes(element);
|
||||
},
|
||||
hasDescendant(element: GfxModel) {
|
||||
return descendantElementsImpl(
|
||||
this as unknown as GfxGroupCompatibleInterface
|
||||
).includes(element);
|
||||
},
|
||||
};
|
||||
|
||||
return group;
|
||||
};
|
||||
|
||||
describe('tree utils', () => {
|
||||
test('batchAddChildren prefers container.addChildren and deduplicates', () => {
|
||||
const a = createElement('a') as unknown as GfxModel;
|
||||
const b = createElement('b') as unknown as GfxModel;
|
||||
const container = {
|
||||
addChildren: vi.fn(),
|
||||
addChild: vi.fn(),
|
||||
};
|
||||
|
||||
batchAddChildren(container as any, [a, a, b]);
|
||||
|
||||
expect(container.addChildren).toHaveBeenCalledTimes(1);
|
||||
expect(container.addChildren).toHaveBeenCalledWith([a, b]);
|
||||
expect(container.addChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('batchRemoveChildren falls back to container.removeChild and deduplicates', () => {
|
||||
const a = createElement('a') as unknown as GfxModel;
|
||||
const b = createElement('b') as unknown as GfxModel;
|
||||
const container = {
|
||||
removeChild: vi.fn(),
|
||||
};
|
||||
|
||||
batchRemoveChildren(container as any, [a, a, b]);
|
||||
|
||||
expect(container.removeChild).toHaveBeenCalledTimes(2);
|
||||
expect(container.removeChild).toHaveBeenNthCalledWith(1, a);
|
||||
expect(container.removeChild).toHaveBeenNthCalledWith(2, b);
|
||||
});
|
||||
|
||||
test('getTopElements removes descendants when ancestors are selected', () => {
|
||||
const root = createGroup('root');
|
||||
const nested = createGroup('nested');
|
||||
const leafA = createElement('leaf-a');
|
||||
const leafB = createElement('leaf-b');
|
||||
const leafC = createElement('leaf-c');
|
||||
|
||||
root.addChild(leafA as unknown as GfxModel);
|
||||
root.addChild(nested as unknown as GfxModel);
|
||||
nested.addChild(leafB as unknown as GfxModel);
|
||||
|
||||
const result = getTopElements([
|
||||
root as unknown as GfxModel,
|
||||
nested as unknown as GfxModel,
|
||||
leafA as unknown as GfxModel,
|
||||
leafB as unknown as GfxModel,
|
||||
leafC as unknown as GfxModel,
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
root as unknown as GfxModel,
|
||||
leafC as unknown as GfxModel,
|
||||
]);
|
||||
});
|
||||
|
||||
test('descendantElementsImpl stops on cyclic graph', () => {
|
||||
const groupA = createGroup('group-a');
|
||||
const groupB = createGroup('group-b');
|
||||
groupA.addChild(groupB as unknown as GfxModel);
|
||||
groupB.addChild(groupA as unknown as GfxModel);
|
||||
|
||||
const descendants = descendantElementsImpl(groupA as unknown as any);
|
||||
|
||||
expect(descendants).toHaveLength(2);
|
||||
expect(new Set(descendants).size).toBe(2);
|
||||
});
|
||||
|
||||
test('canSafeAddToContainer blocks self and circular descendants', () => {
|
||||
const parent = createGroup('parent');
|
||||
const child = createGroup('child');
|
||||
const unrelated = createElement('plain');
|
||||
|
||||
parent.addChild(child as unknown as GfxModel);
|
||||
|
||||
expect(
|
||||
canSafeAddToContainer(parent as unknown as any, parent as unknown as any)
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSafeAddToContainer(child as unknown as any, parent as unknown as any)
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSafeAddToContainer(
|
||||
parent as unknown as any,
|
||||
unrelated as unknown as any
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
1015
blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts
Normal file
1015
blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
65
blocksuite/framework/std/src/__tests__/hast.unit.spec.ts
Normal file
65
blocksuite/framework/std/src/__tests__/hast.unit.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import rehypeParse from 'rehype-parse';
|
||||
import { unified } from 'unified';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { onlyContainImgElement } from '../clipboard/utils.js';
|
||||
|
||||
describe('only contains img elements', () => {
|
||||
test('normal with head', () => {
|
||||
const htmlAst = unified().use(rehypeParse).parse(`<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><!--EndFragment-->
|
||||
</body>
|
||||
</html>`);
|
||||
const isImgOnly =
|
||||
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
|
||||
if (a === 'no' || b === 'no') {
|
||||
return 'no';
|
||||
}
|
||||
if (a === 'maybe' && b === 'maybe') {
|
||||
return 'maybe';
|
||||
}
|
||||
return 'yes';
|
||||
}, 'maybe') === 'yes';
|
||||
expect(isImgOnly).toBe(true);
|
||||
});
|
||||
|
||||
test('normal without head', () => {
|
||||
const htmlAst = unified().use(rehypeParse).parse(`<html>
|
||||
<body>
|
||||
<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><!--EndFragment-->
|
||||
</body>
|
||||
</html>`);
|
||||
const isImgOnly =
|
||||
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
|
||||
if (a === 'no' || b === 'no') {
|
||||
return 'no';
|
||||
}
|
||||
if (a === 'maybe' && b === 'maybe') {
|
||||
return 'maybe';
|
||||
}
|
||||
return 'yes';
|
||||
}, 'maybe') === 'yes';
|
||||
expect(isImgOnly).toBe(true);
|
||||
});
|
||||
|
||||
test('contain spans', () => {
|
||||
const htmlAst = unified().use(rehypeParse).parse(`<html>
|
||||
<body>
|
||||
<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><span></span><!--EndFragment-->
|
||||
</body>
|
||||
</html>`);
|
||||
const isImgOnly =
|
||||
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
|
||||
if (a === 'no' || b === 'no') {
|
||||
return 'no';
|
||||
}
|
||||
if (a === 'maybe' && b === 'maybe') {
|
||||
return 'maybe';
|
||||
}
|
||||
return 'yes';
|
||||
}, 'maybe') === 'yes';
|
||||
expect(isImgOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { shouldDeactivateEditorOnFocusOut } from '../../inline/range/active.js';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '../../inline/range/consts.js';
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('editor active helpers', () => {
|
||||
test('keeps editor active when focus moves into an excluded widget', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
const paragraph = document.createElement('div');
|
||||
host.append(paragraph);
|
||||
|
||||
const widgetRoot = document.createElement('div');
|
||||
widgetRoot.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
const widgetButton = document.createElement('button');
|
||||
widgetRoot.append(widgetButton);
|
||||
|
||||
document.body.append(host, widgetRoot);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, widgetButton)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps editor active when focus stays inside the editor host', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
const input = document.createElement('input');
|
||||
host.append(input);
|
||||
document.body.append(host);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, input)).toBe(false);
|
||||
});
|
||||
|
||||
test('deactivates editor when focus moves to a regular external control', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
const paragraph = document.createElement('div');
|
||||
host.append(paragraph);
|
||||
|
||||
const externalButton = document.createElement('button');
|
||||
document.body.append(host, externalButton);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, externalButton)).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps editor active on focusout when related target is null', () => {
|
||||
// Focus leaving to nowhere (e.g. block-level selection) must not deactivate
|
||||
// the editor via `focusout`; the host `blur` handler owns that case.
|
||||
const host = document.createElement('editor-host');
|
||||
document.body.append(host);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, null)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps editor active when the related target is not a DOM node', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
document.body.append(host);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, new EventTarget())).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
deltaInsertsToChunks,
|
||||
transformDelta,
|
||||
} from '../../inline/utils/delta-convert.js';
|
||||
|
||||
test('transformDelta', () => {
|
||||
expect(
|
||||
transformDelta({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
transformDelta({
|
||||
insert: '\n\naaa\n\nbbb\n\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
'\n',
|
||||
'\n',
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
'\n',
|
||||
'\n',
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
'\n',
|
||||
'\n',
|
||||
]);
|
||||
});
|
||||
|
||||
test('deltaInsertsToChunks', () => {
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: '\n\naaa\nbbb\n\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: '\n\naaa\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: '\nbbb\n\n',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { InlineEditor } from '../../inline/index.js';
|
||||
|
||||
test('getDeltaByRangeIndex', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.applyDelta([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(0)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(1)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(3)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(4)).toEqual({
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('getDeltasByInlineRange', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.applyDelta([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 0,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 3,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 3,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 0,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 2,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('cursor with format', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.setMarks({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb'
|
||||
);
|
||||
|
||||
expect(inlineEditor.yText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('getFormat', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb',
|
||||
{
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 0 })).toEqual({});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 1 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 3 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 0 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 1 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 3 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 6, length: 0 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('incorrect format value `false`', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
// @ts-expect-error insert incorrect value
|
||||
bold: false,
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb',
|
||||
{
|
||||
underline: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(inlineEditor.yText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('yText should not contain \r', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.insert(0, 'aaa\r');
|
||||
|
||||
expect(yText.toString()).toEqual('aaa\r');
|
||||
expect(() => {
|
||||
new InlineEditor(yText);
|
||||
}).toThrow(
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { effects } from '../../effects.js';
|
||||
import { InlineEditor } from '../../inline/index.js';
|
||||
|
||||
effects();
|
||||
|
||||
async function setupInlineEditor(text: string) {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.insert(0, text);
|
||||
|
||||
const editor = new InlineEditor(yText);
|
||||
const root = document.createElement('div');
|
||||
const outside = document.createElement('div');
|
||||
outside.textContent = 'outside';
|
||||
|
||||
document.body.append(root, outside);
|
||||
editor.mount(root);
|
||||
await editor.waitForUpdate();
|
||||
|
||||
return { editor, root, outside };
|
||||
}
|
||||
|
||||
function setNativeSelection(range: Range) {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error('Selection is not available');
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function clearNativeSelection() {
|
||||
const selection = document.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
|
||||
async function teardownInlineEditor(
|
||||
ctx: Awaited<ReturnType<typeof setupInlineEditor>>
|
||||
) {
|
||||
clearNativeSelection();
|
||||
ctx.editor.unmount();
|
||||
ctx.root.remove();
|
||||
ctx.outside.remove();
|
||||
}
|
||||
|
||||
test('beforeinput prevents native edits for selection partially outside inline root', async () => {
|
||||
const ctx = await setupInlineEditor('hello');
|
||||
try {
|
||||
const range = ctx.editor.toDomRange({ index: 1, length: 0 });
|
||||
expect(range).not.toBeNull();
|
||||
range!.setEnd(ctx.outside, 0);
|
||||
setNativeSelection(range!);
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
inputType: 'deleteContentForward',
|
||||
data: null,
|
||||
dataTransfer: null,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
getTargetRanges: () => [],
|
||||
} as unknown as InputEvent;
|
||||
|
||||
await (ctx.editor.eventService as any)._onBeforeInput(event);
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(ctx.editor.yTextString).toBe('h');
|
||||
} finally {
|
||||
await teardownInlineEditor(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
test('beforeinput does not intercept when selection spans another inline root', async () => {
|
||||
const ctx1 = await setupInlineEditor('abc');
|
||||
const ctx2 = await setupInlineEditor('xyz');
|
||||
try {
|
||||
const startRange = ctx1.editor.toDomRange({ index: 1, length: 0 });
|
||||
const endRange = ctx2.editor.toDomRange({ index: 1, length: 0 });
|
||||
expect(startRange).not.toBeNull();
|
||||
expect(endRange).not.toBeNull();
|
||||
|
||||
const selectionRange = document.createRange();
|
||||
selectionRange.setStart(
|
||||
startRange!.startContainer,
|
||||
startRange!.startOffset
|
||||
);
|
||||
selectionRange.setEnd(endRange!.endContainer, endRange!.endOffset);
|
||||
setNativeSelection(selectionRange);
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
inputType: 'deleteContentForward',
|
||||
data: null,
|
||||
dataTransfer: null,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
getTargetRanges: () => [],
|
||||
} as unknown as InputEvent;
|
||||
|
||||
await (ctx1.editor.eventService as any)._onBeforeInput(event);
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(ctx1.editor.yTextString).toBe('abc');
|
||||
} finally {
|
||||
await teardownInlineEditor(ctx1);
|
||||
await teardownInlineEditor(ctx2);
|
||||
}
|
||||
});
|
||||
|
||||
test('beforeinput ignores un-resolvable target range and still applies input', async () => {
|
||||
const ctx = await setupInlineEditor('hello world');
|
||||
try {
|
||||
const range = ctx.editor.toDomRange({ index: 0, length: 5 });
|
||||
expect(range).not.toBeNull();
|
||||
setNativeSelection(range!);
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
inputType: 'insertText',
|
||||
data: 'x',
|
||||
dataTransfer: null,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
getTargetRanges: () => [
|
||||
{
|
||||
startContainer: ctx.outside,
|
||||
startOffset: 0,
|
||||
endContainer: ctx.outside,
|
||||
endOffset: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as InputEvent;
|
||||
|
||||
await (ctx.editor.eventService as any)._onBeforeInput(event);
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(ctx.editor.yTextString).toBe('x world');
|
||||
} finally {
|
||||
await teardownInlineEditor(ctx);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
intersectInlineRange,
|
||||
isInlineRangeAfter,
|
||||
isInlineRangeBefore,
|
||||
isInlineRangeContain,
|
||||
isInlineRangeEdge,
|
||||
isInlineRangeEdgeAfter,
|
||||
isInlineRangeEdgeBefore,
|
||||
isInlineRangeEqual,
|
||||
isInlineRangeIntersect,
|
||||
isPoint,
|
||||
mergeInlineRange,
|
||||
} from '../../inline/utils/inline-range.js';
|
||||
|
||||
test('isInlineRangeContain', () => {
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 2 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 2 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 2 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 3 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 4 })
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
test('isInlineRangeEqual', () => {
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 2 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 1, length: 3 }, { index: 1, length: 3 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 2, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeIntersect', () => {
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 2 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 2 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 2 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeBefore', () => {
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 1 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 2, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 1 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
test('isInlineRangeAfter', () => {
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 2, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 1 }, { index: 2, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 1 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdge', () => {
|
||||
expect(isInlineRangeEdge(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(1, { index: 0, length: 1 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 0, length: 1 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdgeBefore', () => {
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 0, length: 1 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 0, length: 1 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdgeAfter', () => {
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 0, length: 1 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 1 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isPoint', () => {
|
||||
expect(isPoint({ index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 0, length: 2 })).toEqual(false);
|
||||
|
||||
expect(isPoint({ index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 2, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 2, length: 2 })).toEqual(false);
|
||||
});
|
||||
|
||||
test('mergeInlineRange', () => {
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 2, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 2, length: 2 }, { index: 1, length: 1 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 3, length: 2 }, { index: 2, length: 1 })
|
||||
).toEqual({
|
||||
index: 2,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 4 }, { index: 1, length: 1 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 1 }, { index: 0, length: 4 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 2 }, { index: 1, length: 3 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('intersectInlineRange', () => {
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(null);
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 2 }, { index: 1, length: 1 })
|
||||
).toEqual({ index: 1, length: 1 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 2 }, { index: 2, length: 0 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({ index: 1, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 })
|
||||
).toEqual({ index: 2, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 2 }, { index: 0, length: 3 })
|
||||
).toEqual({ index: 1, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 1 }, { index: 2, length: 2 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 2, length: 2 }, { index: 1, length: 3 })
|
||||
).toEqual({ index: 2, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 2, length: 1 }, { index: 1, length: 1 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 4 }, { index: 1, length: 2 })
|
||||
).toEqual({ index: 1, length: 2 });
|
||||
});
|
||||
173
blocksuite/framework/std/src/__tests__/inline/utils.ts
Normal file
173
blocksuite/framework/std/src/__tests__/inline/utils.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import type { InlineEditor, InlineRange } from '../../inline/index.js';
|
||||
|
||||
const defaultPlaygroundURL = new URL(
|
||||
`http://localhost:${process.env.CI ? 4173 : 5173}/`
|
||||
);
|
||||
|
||||
export async function type(page: Page, content: string) {
|
||||
await page.keyboard.type(content, { delay: 50 });
|
||||
}
|
||||
|
||||
export async function press(page: Page, content: string) {
|
||||
await page.keyboard.press(content, { delay: 50 });
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
export async function enterInlineEditorPlayground(page: Page) {
|
||||
const url = new URL('examples/inline/index.html', defaultPlaygroundURL);
|
||||
await page.goto(url.toString());
|
||||
}
|
||||
|
||||
export async function focusInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<void> {
|
||||
await page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
(richTexts[index] as any).inlineEditor.focusEnd();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function getDeltaFromInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<DeltaInsert> {
|
||||
await page.waitForTimeout(100);
|
||||
return page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index] as any).inlineEditor as InlineEditor;
|
||||
return editor.yText.toDelta();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function getInlineRangeFromInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<InlineRange | null> {
|
||||
await page.waitForTimeout(100);
|
||||
return page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index] as any).inlineEditor as InlineEditor;
|
||||
return editor.getInlineRange();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function setInlineRichTextRange(
|
||||
page: Page,
|
||||
inlineRange: InlineRange,
|
||||
index = 0
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
([inlineRange, index]) => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index as number] as any)
|
||||
.inlineEditor as InlineEditor;
|
||||
editor.setInlineRange(inlineRange as InlineRange);
|
||||
},
|
||||
[inlineRange, index]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInlineRichTextLine(
|
||||
page: Page,
|
||||
index: number,
|
||||
i = 0
|
||||
): Promise<readonly [string, number]> {
|
||||
return page.evaluate(
|
||||
([index, i]) => {
|
||||
const richTexts = document.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[i] as any).inlineEditor as InlineEditor;
|
||||
const result = editor.getLine(index);
|
||||
if (!result) {
|
||||
throw new Error('Cannot find line');
|
||||
}
|
||||
const { line, rangeIndexRelatedToLine } = result;
|
||||
return [line.vTextContent, rangeIndexRelatedToLine] as const;
|
||||
},
|
||||
[index, i]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInlineRangeIndexRect(
|
||||
page: Page,
|
||||
[richTextIndex, inlineIndex]: [number, number],
|
||||
coordOffSet: { x: number; y: number } = { x: 0, y: 0 }
|
||||
) {
|
||||
const rect = await page.evaluate(
|
||||
({ richTextIndex, inlineIndex: vIndex, coordOffSet }) => {
|
||||
const richText = document.querySelectorAll('test-rich-text')[
|
||||
richTextIndex
|
||||
] as any;
|
||||
const domRange = richText.inlineEditor.toDomRange({
|
||||
index: vIndex,
|
||||
length: 0,
|
||||
});
|
||||
const pointBound = domRange.getBoundingClientRect();
|
||||
return {
|
||||
x: pointBound.left + coordOffSet.x,
|
||||
y: pointBound.top + pointBound.height / 2 + coordOffSet.y,
|
||||
};
|
||||
},
|
||||
{
|
||||
richTextIndex,
|
||||
inlineIndex,
|
||||
coordOffSet,
|
||||
}
|
||||
);
|
||||
return rect;
|
||||
}
|
||||
|
||||
export async function assertSelection(
|
||||
page: Page,
|
||||
richTextIndex: number,
|
||||
rangeIndex: number,
|
||||
rangeLength = 0
|
||||
) {
|
||||
const actual = await page.evaluate(
|
||||
([richTextIndex]) => {
|
||||
const richText =
|
||||
document?.querySelectorAll('test-rich-text')[richTextIndex];
|
||||
// @ts-expect-error getInlineRange
|
||||
const inlineEditor = richText.inlineEditor;
|
||||
return inlineEditor?.getInlineRange();
|
||||
},
|
||||
[richTextIndex]
|
||||
);
|
||||
expect(actual).toEqual({ index: rangeIndex, length: rangeLength });
|
||||
}
|
||||
203
blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts
Normal file
203
blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { UIEventState, UIEventStateContext } from '../event/base.js';
|
||||
import { androidBindKeymapPatch, bindKeymap } from '../event/keymap.js';
|
||||
|
||||
const createKeyboardEvent = (options: {
|
||||
key: string;
|
||||
keyCode: number;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: options.key,
|
||||
altKey: options.altKey ?? false,
|
||||
ctrlKey: options.ctrlKey ?? false,
|
||||
metaKey: options.metaKey ?? false,
|
||||
shiftKey: options.shiftKey ?? false,
|
||||
});
|
||||
|
||||
Object.defineProperty(event, 'keyCode', {
|
||||
configurable: true,
|
||||
get: () => options.keyCode,
|
||||
});
|
||||
Object.defineProperty(event, 'which', {
|
||||
configurable: true,
|
||||
get: () => options.keyCode,
|
||||
});
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
const createCtx = (event: KeyboardEvent) => {
|
||||
return {
|
||||
get(name: string) {
|
||||
if (name === 'keyboardState') {
|
||||
return { raw: event };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
describe('bindKeymap', () => {
|
||||
test('falls back to physical key for ctrl shortcuts on non-US layouts', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Ctrl-f': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'а',
|
||||
keyCode: 70,
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(true);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
test('does not fallback for Alt+locale-character letter input', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Alt-s': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'ś',
|
||||
keyCode: 83,
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(false);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps Alt+digit fallback for non-ASCII key outputs', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Alt-0': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'º',
|
||||
keyCode: 48,
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(true);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
test('does not fallback on non-ASCII input without modifiers', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'[': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'х',
|
||||
keyCode: 219,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(false);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidBindKeymapPatch', () => {
|
||||
const beforeInputCtx = (inputType: string) => {
|
||||
const event = new InputEvent('beforeinput', {
|
||||
inputType,
|
||||
cancelable: true,
|
||||
});
|
||||
return { ctx: UIEventStateContext.from(new UIEventState(event)), event };
|
||||
};
|
||||
|
||||
test('routes deleteContentBackward to the Backspace binding', () => {
|
||||
const backspace = vi.fn(() => true);
|
||||
const handler = androidBindKeymapPatch({ Backspace: backspace });
|
||||
const { ctx } = beforeInputCtx('deleteContentBackward');
|
||||
|
||||
expect(handler(ctx)).toBe(true);
|
||||
expect(backspace).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('routes insertParagraph to the Enter binding', () => {
|
||||
const enter = vi.fn((ctx: UIEventStateContext) => {
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
});
|
||||
const handler = androidBindKeymapPatch({ Enter: enter });
|
||||
const { ctx, event } = beforeInputCtx('insertParagraph');
|
||||
const preventDefault = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
expect(handler(ctx)).toBe(true);
|
||||
expect(enter).toHaveBeenCalledOnce();
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(ctx.get('keyboardState').raw.key).toBe('Enter');
|
||||
expect(ctx.get('keyboardState').composing).toBe(false);
|
||||
});
|
||||
|
||||
test('propagates preventDefault when the binding returns false', () => {
|
||||
const backspace = vi.fn((ctx: UIEventStateContext) => {
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return false;
|
||||
});
|
||||
const handler = androidBindKeymapPatch({ Backspace: backspace });
|
||||
const { ctx, event } = beforeInputCtx('deleteContentBackward');
|
||||
|
||||
expect(handler(ctx)).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test('does nothing for insertParagraph without an Enter binding', () => {
|
||||
const handler = androidBindKeymapPatch({ Backspace: vi.fn(() => true) });
|
||||
const { ctx } = beforeInputCtx('insertParagraph');
|
||||
|
||||
expect(handler(ctx)).toBe(false);
|
||||
expect(ctx.has('keyboardState')).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores non-input events', () => {
|
||||
const enter = vi.fn(() => true);
|
||||
const backspace = vi.fn(() => true);
|
||||
const ctx = UIEventStateContext.from(
|
||||
new UIEventState(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
);
|
||||
|
||||
expect(
|
||||
androidBindKeymapPatch({ Enter: enter, Backspace: backspace })(ctx)
|
||||
).toBeUndefined();
|
||||
expect(enter).not.toHaveBeenCalled();
|
||||
expect(backspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores unrelated input types', () => {
|
||||
const enter = vi.fn(() => true);
|
||||
const backspace = vi.fn(() => true);
|
||||
const handler = androidBindKeymapPatch({
|
||||
Enter: enter,
|
||||
Backspace: backspace,
|
||||
});
|
||||
const { ctx } = beforeInputCtx('insertText');
|
||||
|
||||
expect(handler(ctx)).toBe(false);
|
||||
expect(enter).not.toHaveBeenCalled();
|
||||
expect(backspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
61
blocksuite/framework/std/src/__tests__/test-block.ts
Normal file
61
blocksuite/framework/std/src/__tests__/test-block.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
import { BlockComponent, GfxBlockComponent } from '../view/index.js';
|
||||
import type {
|
||||
HeadingBlockModel,
|
||||
NoteBlockModel,
|
||||
RootBlockModel,
|
||||
SurfaceBlockModel,
|
||||
TestGfxBlockModel,
|
||||
} from './test-schema.js';
|
||||
|
||||
@customElement('test-root-block')
|
||||
export class RootBlockComponent extends BlockComponent<RootBlockModel> {
|
||||
override renderBlock() {
|
||||
return html`
|
||||
<div class="test-root-block">${this.renderChildren(this.model)}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('test-note-block')
|
||||
export class NoteBlockComponent extends BlockComponent<NoteBlockModel> {
|
||||
override renderBlock() {
|
||||
return html`
|
||||
<div class="test-note-block">${this.renderChildren(this.model)}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('test-h1-block')
|
||||
export class HeadingH1BlockComponent extends BlockComponent<HeadingBlockModel> {
|
||||
override renderBlock() {
|
||||
return html` <div class="test-heading-block h1">${this.model.text}</div> `;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('test-h2-block')
|
||||
export class HeadingH2BlockComponent extends BlockComponent<HeadingBlockModel> {
|
||||
override renderBlock() {
|
||||
return html` <div class="test-heading-block h2">${this.model.text}</div> `;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('test-surface-block')
|
||||
export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
|
||||
override renderBlock() {
|
||||
return html`
|
||||
<div class="test-surface-block">${this.renderChildren(this.model)}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('test-gfx-block')
|
||||
export class TestGfxBlockComponent extends GfxBlockComponent<TestGfxBlockModel> {
|
||||
override renderGfxBlock() {
|
||||
return html`
|
||||
<div class="test-gfx-block">${this.renderChildren(this.model)}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
38
blocksuite/framework/std/src/__tests__/test-editor.ts
Normal file
38
blocksuite/framework/std/src/__tests__/test-editor.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { ExtensionType, Store } from '@blocksuite/store';
|
||||
import { html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import { BlockStdScope } from '../scope/index.js';
|
||||
import { ShadowlessElement } from '../view/index.js';
|
||||
|
||||
@customElement('test-editor-container')
|
||||
export class TestEditorContainer extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private _std!: BlockStdScope;
|
||||
|
||||
get std() {
|
||||
return this._std;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._std = new BlockStdScope({
|
||||
store: this.doc,
|
||||
extensions: this.specs,
|
||||
});
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return html` <div class="test-editor-container">
|
||||
${this._std.render()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Store;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor specs: ExtensionType[] = [];
|
||||
}
|
||||
44
blocksuite/framework/std/src/__tests__/test-gfx-element.ts
Normal file
44
blocksuite/framework/std/src/__tests__/test-gfx-element.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { SerializedXYWH } from '@blocksuite/global/gfx';
|
||||
|
||||
import {
|
||||
convert,
|
||||
derive,
|
||||
field,
|
||||
GfxLocalElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from '../gfx/index.js';
|
||||
|
||||
export class TestShapeElement extends GfxPrimitiveElementModel {
|
||||
get type() {
|
||||
return 'testShape';
|
||||
}
|
||||
|
||||
@field()
|
||||
accessor rotate: number = 0;
|
||||
|
||||
@field()
|
||||
accessor xywh: SerializedXYWH = '[0,0,10,10]';
|
||||
|
||||
@convert(val => {
|
||||
if (['rect', 'triangle'].includes(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
return 'rect';
|
||||
})
|
||||
@derive(val => {
|
||||
if (val === 'triangle') {
|
||||
return {
|
||||
rotate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
})
|
||||
@field()
|
||||
accessor shapeType: 'rect' | 'triangle' = 'rect';
|
||||
}
|
||||
|
||||
export class TestLocalElement extends GfxLocalElementModel {
|
||||
override type: string = 'testLocal';
|
||||
}
|
||||
127
blocksuite/framework/std/src/__tests__/test-schema.ts
Normal file
127
blocksuite/framework/std/src/__tests__/test-schema.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { SerializedXYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
BlockModel,
|
||||
BlockSchemaExtension,
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { SurfaceBlockModel as BaseSurfaceModel } from '../gfx/index.js';
|
||||
import {
|
||||
GfxCompatibleBlockModel,
|
||||
type GfxCompatibleProps,
|
||||
} from '../gfx/model/gfx-block-model.js';
|
||||
import { TestShapeElement } from './test-gfx-element.js';
|
||||
|
||||
export const RootBlockSchema = defineBlockSchema({
|
||||
flavour: 'test:page',
|
||||
props: internal => ({
|
||||
title: internal.Text(),
|
||||
count: 0,
|
||||
style: {} as Record<string, unknown>,
|
||||
items: [] as unknown[],
|
||||
}),
|
||||
metadata: {
|
||||
version: 2,
|
||||
role: 'root',
|
||||
children: ['test:note', 'test:surface'],
|
||||
},
|
||||
});
|
||||
|
||||
export const RootBlockSchemaExtension = BlockSchemaExtension(RootBlockSchema);
|
||||
|
||||
export class RootBlockModel extends BlockModel<
|
||||
ReturnType<(typeof RootBlockSchema)['model']['props']>
|
||||
> {}
|
||||
|
||||
export const NoteBlockSchema = defineBlockSchema({
|
||||
flavour: 'test:note',
|
||||
props: () => ({}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
role: 'hub',
|
||||
parent: ['test:page'],
|
||||
children: ['test:heading'],
|
||||
},
|
||||
});
|
||||
|
||||
export const NoteBlockSchemaExtension = BlockSchemaExtension(NoteBlockSchema);
|
||||
|
||||
export class NoteBlockModel extends BlockModel<
|
||||
ReturnType<(typeof NoteBlockSchema)['model']['props']>
|
||||
> {}
|
||||
|
||||
export const HeadingBlockSchema = defineBlockSchema({
|
||||
flavour: 'test:heading',
|
||||
props: internal => ({
|
||||
type: 'h1',
|
||||
text: internal.Text(),
|
||||
}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
role: 'content',
|
||||
parent: ['test:note'],
|
||||
},
|
||||
});
|
||||
|
||||
export const HeadingBlockSchemaExtension =
|
||||
BlockSchemaExtension(HeadingBlockSchema);
|
||||
|
||||
export class HeadingBlockModel extends BlockModel<
|
||||
ReturnType<(typeof HeadingBlockSchema)['model']['props']>
|
||||
> {}
|
||||
|
||||
export const SurfaceBlockSchema = defineBlockSchema({
|
||||
flavour: 'test:surface',
|
||||
props: internal => ({
|
||||
elements: internal.Boxed<Y.Map<Y.Map<unknown>>>(new Y.Map()),
|
||||
}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
role: 'hub',
|
||||
parent: ['test:page'],
|
||||
},
|
||||
toModel: () => new SurfaceBlockModel(),
|
||||
});
|
||||
|
||||
export const SurfaceBlockSchemaExtension =
|
||||
BlockSchemaExtension(SurfaceBlockSchema);
|
||||
|
||||
export class SurfaceBlockModel extends BaseSurfaceModel {
|
||||
override _init() {
|
||||
this._extendElement({
|
||||
testShape: TestShapeElement,
|
||||
});
|
||||
super._init();
|
||||
}
|
||||
}
|
||||
|
||||
type GfxTestBlockProps = {
|
||||
xywh: SerializedXYWH;
|
||||
rotate: number;
|
||||
index: string;
|
||||
} & GfxCompatibleProps;
|
||||
|
||||
export const TestGfxBlockSchema = defineBlockSchema({
|
||||
flavour: 'test:gfx-block',
|
||||
props: () =>
|
||||
({
|
||||
xywh: '[0,0,10,10]' as SerializedXYWH,
|
||||
rotate: 0,
|
||||
index: 'a0',
|
||||
lockedBySelf: false,
|
||||
}) as GfxTestBlockProps,
|
||||
metadata: {
|
||||
version: 1,
|
||||
role: 'content',
|
||||
parent: ['test:surface'],
|
||||
},
|
||||
toModel: () => new TestGfxBlockModel(),
|
||||
});
|
||||
|
||||
export const TestGfxBlockSchemaExtension =
|
||||
BlockSchemaExtension(TestGfxBlockSchema);
|
||||
|
||||
export class TestGfxBlockModel extends GfxCompatibleBlockModel<GfxTestBlockProps>(
|
||||
BlockModel
|
||||
) {}
|
||||
27
blocksuite/framework/std/src/__tests__/test-spec.ts
Normal file
27
blocksuite/framework/std/src/__tests__/test-spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import './test-block.js';
|
||||
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { BlockViewExtension } from '../extension/index.js';
|
||||
import type { HeadingBlockModel } from './test-schema.js';
|
||||
|
||||
export const testSpecs: ExtensionType[] = [
|
||||
BlockViewExtension('test:page', literal`test-root-block`),
|
||||
|
||||
BlockViewExtension('test:note', literal`test-note-block`),
|
||||
|
||||
BlockViewExtension('test:heading', model => {
|
||||
const h = (model as HeadingBlockModel).props.type$.value;
|
||||
|
||||
if (h === 'h1') {
|
||||
return literal`test-h1-block`;
|
||||
}
|
||||
|
||||
return literal`test-h2-block`;
|
||||
}),
|
||||
|
||||
BlockViewExtension('test:surface', literal`test-surface-block`),
|
||||
|
||||
BlockViewExtension('test:gfx-block', literal`test-gfx-block`),
|
||||
];
|
||||
33
blocksuite/framework/std/src/clipboard/clipboard-adapter.ts
Normal file
33
blocksuite/framework/std/src/clipboard/clipboard-adapter.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di';
|
||||
import type {
|
||||
BaseAdapter,
|
||||
ExtensionType,
|
||||
Transformer,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
type AdapterConstructor = new (
|
||||
job: Transformer,
|
||||
provider: ServiceProvider
|
||||
) => BaseAdapter;
|
||||
|
||||
export interface ClipboardAdapterConfig {
|
||||
mimeType: string;
|
||||
priority: number;
|
||||
adapter: AdapterConstructor;
|
||||
}
|
||||
|
||||
export const ClipboardAdapterConfigIdentifier =
|
||||
createIdentifier<ClipboardAdapterConfig>('clipboard-adapter-config');
|
||||
|
||||
export function ClipboardAdapterConfigExtension(
|
||||
config: ClipboardAdapterConfig
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(
|
||||
ClipboardAdapterConfigIdentifier(config.mimeType),
|
||||
() => config
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
323
blocksuite/framework/std/src/clipboard/clipboard.ts
Normal file
323
blocksuite/framework/std/src/clipboard/clipboard.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type {
|
||||
BlockSnapshot,
|
||||
Slice,
|
||||
Store,
|
||||
TransformerMiddleware,
|
||||
} from '@blocksuite/store';
|
||||
import DOMPurify from 'dompurify';
|
||||
import * as lz from 'lz-string';
|
||||
import rehypeParse from 'rehype-parse';
|
||||
import { unified } from 'unified';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { ClipboardAdapterConfigIdentifier } from './clipboard-adapter.js';
|
||||
import { onlyContainImgElement } from './utils.js';
|
||||
|
||||
export class Clipboard extends LifeCycleWatcher {
|
||||
static override key = 'clipboard';
|
||||
|
||||
protected get _adapters() {
|
||||
const adapterConfigs = this.std.provider.getAll(
|
||||
ClipboardAdapterConfigIdentifier
|
||||
);
|
||||
return Array.from(adapterConfigs.values());
|
||||
}
|
||||
|
||||
// Need to be cloned to a map for later use
|
||||
private readonly _getDataByType = (clipboardData: DataTransfer) => {
|
||||
const data = new Map<string, string | File[]>();
|
||||
for (const type of clipboardData.types) {
|
||||
if (type === 'Files') {
|
||||
data.set(type, Array.from(clipboardData.files));
|
||||
} else {
|
||||
data.set(type, clipboardData.getData(type));
|
||||
}
|
||||
}
|
||||
if (data.get('Files') && data.get('text/html')) {
|
||||
const htmlAst = unified()
|
||||
.use(rehypeParse)
|
||||
.parse(data.get('text/html') as string);
|
||||
|
||||
const isImgOnly =
|
||||
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
|
||||
if (a === 'no' || b === 'no') {
|
||||
return 'no';
|
||||
}
|
||||
if (a === 'maybe' && b === 'maybe') {
|
||||
return 'maybe';
|
||||
}
|
||||
return 'yes';
|
||||
}, 'maybe') === 'yes';
|
||||
|
||||
if (isImgOnly) {
|
||||
data.delete('text/html');
|
||||
}
|
||||
}
|
||||
return (type: string) => {
|
||||
const item = data.get(type);
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
const files = (data.get('Files') ?? []) as File[];
|
||||
if (files.length > 0) {
|
||||
return files;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _getSnapshotByPriority = async (
|
||||
getItem: (type: string) => string | File[],
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
const byPriority = Array.from(this._adapters).sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
);
|
||||
|
||||
for (const { adapter, mimeType } of byPriority) {
|
||||
const item = getItem(mimeType);
|
||||
if (Array.isArray(item)) {
|
||||
if (item.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
// if all files are not the same target type, fallback to */*
|
||||
!item
|
||||
.map(f => f.type === mimeType || mimeType === '*/*')
|
||||
.reduce((a, b) => a && b, true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (item) {
|
||||
const job = this._getJob();
|
||||
const adapterInstance = new adapter(job, this.std.store.provider);
|
||||
const payload = {
|
||||
file: item,
|
||||
assets: job.assetsManager,
|
||||
workspaceId: doc.workspace.id,
|
||||
pageId: doc.id,
|
||||
};
|
||||
const result = await adapterInstance.toSlice(
|
||||
payload,
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private _jobMiddlewares: TransformerMiddleware[] = [];
|
||||
|
||||
copy = async (slice: Slice) => {
|
||||
return this.copySlice(slice);
|
||||
};
|
||||
|
||||
// Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation
|
||||
copySlice = async (slice: Slice) => {
|
||||
const adapterKeys = this._adapters.map(adapter => adapter.mimeType);
|
||||
|
||||
await this.writeToClipboard(async items => {
|
||||
const filtered = (
|
||||
await Promise.all(
|
||||
adapterKeys.map(async type => {
|
||||
const item = await this._getClipboardItem(slice, type);
|
||||
if (typeof item === 'string') {
|
||||
return [type, item];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
)
|
||||
).filter((adapter): adapter is string[] => Boolean(adapter));
|
||||
|
||||
return {
|
||||
...items,
|
||||
...Object.fromEntries(filtered),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
duplicateSlice = async (
|
||||
slice: Slice,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number,
|
||||
type = 'BLOCKSUITE/SNAPSHOT'
|
||||
) => {
|
||||
const items = {
|
||||
[type]: await this._getClipboardItem(slice, type),
|
||||
};
|
||||
|
||||
await this._getSnapshotByPriority(
|
||||
type => (items[type] as string | File[]) ?? '',
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
};
|
||||
|
||||
paste = async (
|
||||
event: ClipboardEvent,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
const data = event.clipboardData;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = this.readFromClipboard(data);
|
||||
const slice = await this._getSnapshotByPriority(
|
||||
type => json[type],
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
if (!slice) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerError,
|
||||
'No snapshot found'
|
||||
);
|
||||
}
|
||||
return slice;
|
||||
} catch {
|
||||
const getDataByType = this._getDataByType(data);
|
||||
const slice = await this._getSnapshotByPriority(
|
||||
type => getDataByType(type),
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
return slice;
|
||||
}
|
||||
};
|
||||
|
||||
pasteBlockSnapshot = async (
|
||||
snapshot: BlockSnapshot,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
return this._getJob().snapshotToBlock(snapshot, doc, parent, index);
|
||||
};
|
||||
|
||||
unuse = (middleware: TransformerMiddleware) => {
|
||||
this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware);
|
||||
};
|
||||
|
||||
use = (middleware: TransformerMiddleware) => {
|
||||
this._jobMiddlewares.push(middleware);
|
||||
};
|
||||
|
||||
get configs() {
|
||||
return this._getJob().adapterConfigs;
|
||||
}
|
||||
|
||||
private async _getClipboardItem(slice: Slice, type: string) {
|
||||
const job = this._getJob();
|
||||
const adapterItem = this.std.getOptional(
|
||||
ClipboardAdapterConfigIdentifier(type)
|
||||
);
|
||||
if (!adapterItem) {
|
||||
return;
|
||||
}
|
||||
const { adapter } = adapterItem;
|
||||
const adapterInstance = new adapter(job, this.std.store.provider);
|
||||
const result = await adapterInstance.fromSlice(slice);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
return result.file;
|
||||
}
|
||||
|
||||
private _getJob() {
|
||||
return this.std.store.getTransformer(this._jobMiddlewares);
|
||||
}
|
||||
|
||||
readFromClipboard(clipboardData: DataTransfer) {
|
||||
const items = clipboardData.getData('text/html');
|
||||
const sanitizedItems = DOMPurify.sanitize(items);
|
||||
const domParser = new DOMParser();
|
||||
const doc = domParser.parseFromString(sanitizedItems, 'text/html');
|
||||
const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]');
|
||||
if (!dom) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerError,
|
||||
'No snapshot found'
|
||||
);
|
||||
}
|
||||
const json = JSON.parse(
|
||||
lz.decompressFromEncodedURIComponent(
|
||||
dom.dataset.blocksuiteSnapshot as string
|
||||
)
|
||||
);
|
||||
return json;
|
||||
}
|
||||
|
||||
sliceToSnapshot(slice: Slice) {
|
||||
const job = this._getJob();
|
||||
return job.sliceToSnapshot(slice);
|
||||
}
|
||||
|
||||
async writeToClipboard(
|
||||
updateItems: <T extends Record<string, unknown>>(items: T) => Promise<T> | T
|
||||
) {
|
||||
const items = await updateItems<
|
||||
Partial<{
|
||||
'text/plain': string;
|
||||
'text/html': string;
|
||||
'image/png': string | Blob;
|
||||
}>
|
||||
>({
|
||||
'text/plain': '',
|
||||
'text/html': '',
|
||||
'image/png': '',
|
||||
});
|
||||
const text = items['text/plain'] ?? '';
|
||||
const innerHTML = items['text/html'] ?? '';
|
||||
const image = items['image/png'];
|
||||
|
||||
delete items['text/plain'];
|
||||
delete items['text/html'];
|
||||
|
||||
const clipboardItems: Record<string, Blob> = {};
|
||||
|
||||
if (image) {
|
||||
const type = 'image/png';
|
||||
delete items[type];
|
||||
if (typeof image === 'string') {
|
||||
clipboardItems[type] = new Blob([image], { type });
|
||||
} else if (image instanceof Blob) {
|
||||
clipboardItems[type] = image;
|
||||
}
|
||||
}
|
||||
|
||||
if (text.length > 0) {
|
||||
const type = 'text/plain';
|
||||
clipboardItems[type] = new Blob([text], { type });
|
||||
}
|
||||
|
||||
const hasInnerHTML = Boolean(innerHTML.length);
|
||||
const isEmpty = Object.keys(clipboardItems).length === 0;
|
||||
|
||||
// If there are no items, fall back to snapshot.
|
||||
if (hasInnerHTML || isEmpty) {
|
||||
const type = 'text/html';
|
||||
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
|
||||
const html = `<div data-blocksuite-snapshot="${snapshot}">${innerHTML}</div>`;
|
||||
clipboardItems[type] = new Blob([html], { type });
|
||||
}
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
|
||||
}
|
||||
}
|
||||
2
blocksuite/framework/std/src/clipboard/index.ts
Normal file
2
blocksuite/framework/std/src/clipboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './clipboard';
|
||||
export * from './clipboard-adapter';
|
||||
33
blocksuite/framework/std/src/clipboard/utils.ts
Normal file
33
blocksuite/framework/std/src/clipboard/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RootContentMap } from 'hast';
|
||||
|
||||
type HastUnionType<
|
||||
K extends keyof RootContentMap,
|
||||
V extends RootContentMap[K],
|
||||
> = V;
|
||||
|
||||
export function onlyContainImgElement(
|
||||
ast: HastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
|
||||
): 'yes' | 'no' | 'maybe' {
|
||||
if (ast.type === 'element') {
|
||||
switch (ast.tagName) {
|
||||
case 'html':
|
||||
case 'body':
|
||||
return ast.children.map(onlyContainImgElement).reduce((a, b) => {
|
||||
if (a === 'no' || b === 'no') {
|
||||
return 'no';
|
||||
}
|
||||
if (a === 'maybe' && b === 'maybe') {
|
||||
return 'maybe';
|
||||
}
|
||||
return 'yes';
|
||||
}, 'maybe');
|
||||
case 'img':
|
||||
return 'yes';
|
||||
case 'head':
|
||||
return 'maybe';
|
||||
default:
|
||||
return 'no';
|
||||
}
|
||||
}
|
||||
return 'maybe';
|
||||
}
|
||||
1
blocksuite/framework/std/src/command/consts.ts
Normal file
1
blocksuite/framework/std/src/command/consts.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const cmdSymbol = Symbol('cmds');
|
||||
3
blocksuite/framework/std/src/command/index.ts
Normal file
3
blocksuite/framework/std/src/command/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './consts.js';
|
||||
export * from './manager.js';
|
||||
export * from './types.js';
|
||||
237
blocksuite/framework/std/src/command/manager.ts
Normal file
237
blocksuite/framework/std/src/command/manager.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { cmdSymbol } from './consts.js';
|
||||
import type { Chain, Command, InitCommandCtx } from './types.js';
|
||||
|
||||
/**
|
||||
* Command manager to manage all commands
|
||||
*
|
||||
* Commands are functions that take a context and a next function as arguments
|
||||
*
|
||||
* ```ts
|
||||
* const myCommand: Command<input, output> = (ctx, next) => {
|
||||
* const count = ctx.count || 0;
|
||||
*
|
||||
* const success = someOperation();
|
||||
* if (success) {
|
||||
* return next({ count: count + 1 });
|
||||
* }
|
||||
* // if the command is not successful, you can return without calling next
|
||||
* return;
|
||||
* ```
|
||||
*
|
||||
* Command input and output data can be defined in the `Command` type
|
||||
*
|
||||
* ```ts
|
||||
* // input: ctx.firstName, ctx.lastName
|
||||
* // output: ctx.fullName
|
||||
* const myCommand: Command<{ firstName: string; lastName: string }, { fullName: string }> = (ctx, next) => {
|
||||
* const { firstName, lastName } = ctx;
|
||||
* const fullName = `${firstName} ${lastName}`;
|
||||
* return next({ fullName });
|
||||
* }
|
||||
*
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* Commands can be run in two ways:
|
||||
*
|
||||
* 1. Using `exec` method
|
||||
* `exec` is used to run a single command
|
||||
* ```ts
|
||||
* const [result, data] = commandManager.exec(myCommand, payload);
|
||||
* ```
|
||||
*
|
||||
* 2. Using `chain` method
|
||||
* `chain` is used to run a series of commands
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .pipe(myCommand1)
|
||||
* .pipe(myCommand2, payload)
|
||||
* .run();
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* Command chains will stop running if a command is not successful
|
||||
*
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .chain(myCommand1) <-- if this fail
|
||||
* .chain(myCommand2, payload) <- this won't run
|
||||
* .run();
|
||||
*
|
||||
* result <- result will be `false`
|
||||
* ```
|
||||
*
|
||||
* You can use `try` to run a series of commands and if one of them is successful, it will continue to the next command
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .try(chain => [
|
||||
* chain.pipe(myCommand1), <- if this fail
|
||||
* chain.pipe(myCommand2, payload), <- this will run, if this success
|
||||
* chain.pipe(myCommand3), <- this won't run
|
||||
* ])
|
||||
* .run();
|
||||
* ```
|
||||
*
|
||||
* The `tryAll` method is similar to `try`, but it will run all commands even if one of them is successful
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .try(chain => [
|
||||
* chain.pipe(myCommand1), <- if this success
|
||||
* chain.pipe(myCommand2), <- this will also run
|
||||
* chain.pipe(myCommand3), <- so will this
|
||||
* ])
|
||||
* .run();
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
export class CommandManager extends LifeCycleWatcher {
|
||||
static override readonly key = 'commandManager';
|
||||
|
||||
private readonly _createChain = (_cmds: Command[]): Chain => {
|
||||
const getCommandCtx = this._getCommandCtx;
|
||||
const createChain = this._createChain;
|
||||
const chain = this.chain;
|
||||
|
||||
return {
|
||||
[cmdSymbol]: _cmds,
|
||||
run: function (this: Chain) {
|
||||
let ctx = getCommandCtx();
|
||||
let success = false;
|
||||
try {
|
||||
const cmds = this[cmdSymbol];
|
||||
ctx = runCmds(ctx, [
|
||||
...cmds,
|
||||
(_, next) => {
|
||||
success = true;
|
||||
next();
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return [success, ctx];
|
||||
},
|
||||
with: function (this: Chain, value) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([...cmds, (_, next) => next(value)]) as never;
|
||||
},
|
||||
pipe: function (this: Chain, command: Command, input?: object) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([
|
||||
...cmds,
|
||||
(ctx, next) => command({ ...ctx, ...input }, next),
|
||||
]);
|
||||
},
|
||||
try: function (this: Chain, fn) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([
|
||||
...cmds,
|
||||
(beforeCtx, next) => {
|
||||
let ctx = beforeCtx;
|
||||
|
||||
const commands = fn(chain());
|
||||
|
||||
commands.some(innerChain => {
|
||||
innerChain[cmdSymbol] = [
|
||||
(_, next) => {
|
||||
next(ctx);
|
||||
},
|
||||
...innerChain[cmdSymbol],
|
||||
];
|
||||
|
||||
const [success, branchCtx] = innerChain.run();
|
||||
ctx = { ...ctx, ...branchCtx };
|
||||
|
||||
if (success) {
|
||||
next(ctx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
]) as never;
|
||||
},
|
||||
tryAll: function (this: Chain, fn) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([
|
||||
...cmds,
|
||||
(beforeCtx, next) => {
|
||||
let ctx = beforeCtx;
|
||||
|
||||
let allFail = true;
|
||||
|
||||
const commands = fn(chain());
|
||||
|
||||
commands.forEach(innerChain => {
|
||||
innerChain[cmdSymbol] = [
|
||||
(_, next) => {
|
||||
next(ctx);
|
||||
},
|
||||
...innerChain[cmdSymbol],
|
||||
];
|
||||
|
||||
const [success, branchCtx] = innerChain.run();
|
||||
ctx = { ...ctx, ...branchCtx };
|
||||
|
||||
if (success) {
|
||||
allFail = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!allFail) {
|
||||
next(ctx);
|
||||
}
|
||||
},
|
||||
]) as never;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _getCommandCtx = (): InitCommandCtx => {
|
||||
return {
|
||||
std: this.std,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a chain to run a series of commands
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .myCommand1()
|
||||
* .myCommand2(payload)
|
||||
* .run();
|
||||
* ```
|
||||
* @returns [success, data] - success is a boolean to indicate if the chain is successful,
|
||||
* data is the final context after running the chain
|
||||
*/
|
||||
chain = (): Chain<InitCommandCtx> => {
|
||||
return this._createChain([]);
|
||||
};
|
||||
|
||||
exec = <Output extends object, Input extends object>(
|
||||
command: Command<Input, Output>,
|
||||
input?: Input
|
||||
) => {
|
||||
return this.chain().pipe(command, input).run();
|
||||
};
|
||||
}
|
||||
|
||||
function runCmds(ctx: InitCommandCtx, [cmd, ...rest]: Command[]) {
|
||||
let _ctx = ctx;
|
||||
if (cmd) {
|
||||
cmd(ctx, data => {
|
||||
_ctx = runCmds({ ...ctx, ...data }, rest);
|
||||
});
|
||||
}
|
||||
return _ctx;
|
||||
}
|
||||
42
blocksuite/framework/std/src/command/types.ts
Normal file
42
blocksuite/framework/std/src/command/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// type A = {};
|
||||
// type B = { prop?: string };
|
||||
// type C = { prop: string };
|
||||
// type TestA = MakeOptionalIfEmpty<A>; // void | {}
|
||||
// type TestB = MakeOptionalIfEmpty<B>; // void | { prop?: string }
|
||||
// type TestC = MakeOptionalIfEmpty<C>; // { prop: string }
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
import type { cmdSymbol } from './consts.js';
|
||||
|
||||
export interface InitCommandCtx {
|
||||
std: BlockStdScope;
|
||||
}
|
||||
|
||||
export type Cmds = {
|
||||
[cmdSymbol]: Command[];
|
||||
};
|
||||
|
||||
export type Command<Input = InitCommandCtx, Output = {}> = (
|
||||
input: Input & InitCommandCtx,
|
||||
next: (output?: Output) => void
|
||||
) => void;
|
||||
|
||||
export type Chain<CommandCtx extends object = InitCommandCtx> = {
|
||||
[cmdSymbol]: Command[];
|
||||
with: <Out extends object>(input: Out) => Chain<CommandCtx & Out>;
|
||||
pipe: {
|
||||
<Out extends object>(
|
||||
command: Command<CommandCtx, Out>
|
||||
): Chain<CommandCtx & Out>;
|
||||
<Out extends object, In extends object>(
|
||||
command: Command<In, Out>,
|
||||
input?: In
|
||||
): Chain<CommandCtx & In & Out>;
|
||||
};
|
||||
try: <Out extends object>(
|
||||
commands: (chain: Chain<CommandCtx>) => Chain<CommandCtx & Out>[]
|
||||
) => Chain<CommandCtx & Out>;
|
||||
tryAll: <Out extends object>(
|
||||
commands: (chain: Chain<CommandCtx>) => Chain<CommandCtx & Out>[]
|
||||
) => Chain<CommandCtx & Out>;
|
||||
run: () => [false, Partial<CommandCtx> & InitCommandCtx] | [true, CommandCtx];
|
||||
};
|
||||
14
blocksuite/framework/std/src/effects.ts
Normal file
14
blocksuite/framework/std/src/effects.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { GfxViewportElement } from './gfx/viewport-element.js';
|
||||
import { VElement, VLine, VText } from './inline/index.js';
|
||||
import { EditorHost } from './view/index.js';
|
||||
|
||||
export function effects() {
|
||||
// editor host
|
||||
customElements.define('editor-host', EditorHost);
|
||||
// gfx
|
||||
customElements.define('gfx-viewport', GfxViewportElement);
|
||||
// inline
|
||||
customElements.define('v-element', VElement);
|
||||
customElements.define('v-line', VLine);
|
||||
customElements.define('v-text', VText);
|
||||
}
|
||||
62
blocksuite/framework/std/src/event/base.ts
Normal file
62
blocksuite/framework/std/src/event/base.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
type MatchEvent<T extends string> = T extends UIEventStateType
|
||||
? BlockSuiteUIEventState[T]
|
||||
: UIEventState;
|
||||
|
||||
export class UIEventState {
|
||||
/** when extends, override it with pattern `xxxState` */
|
||||
type = 'defaultState';
|
||||
|
||||
constructor(public event: Event) {}
|
||||
}
|
||||
|
||||
export class UIEventStateContext {
|
||||
private _map: Record<string, UIEventState> = {};
|
||||
|
||||
add = <State extends UIEventState = UIEventState>(state: State) => {
|
||||
const name = state.type;
|
||||
if (this._map[name]) {
|
||||
console.warn('UIEventStateContext: state name duplicated', name);
|
||||
}
|
||||
|
||||
this._map[name] = state;
|
||||
};
|
||||
|
||||
get = <Type extends UIEventStateType = UIEventStateType>(
|
||||
type: Type
|
||||
): MatchEvent<Type> => {
|
||||
const state = this._map[type];
|
||||
if (!state) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EventDispatcherError,
|
||||
`UIEventStateContext: state ${type} not found`
|
||||
);
|
||||
}
|
||||
return state as MatchEvent<Type>;
|
||||
};
|
||||
|
||||
has = (type: UIEventStateType) => {
|
||||
return !!this._map[type];
|
||||
};
|
||||
|
||||
static from(...states: UIEventState[]) {
|
||||
const context = new UIEventStateContext();
|
||||
states.forEach(state => {
|
||||
context.add(state);
|
||||
});
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
export type UIEventHandler = (
|
||||
context: UIEventStateContext
|
||||
) => boolean | null | undefined | void;
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
defaultState: UIEventState;
|
||||
}
|
||||
|
||||
type UIEventStateType = keyof BlockSuiteUIEventState;
|
||||
}
|
||||
55
blocksuite/framework/std/src/event/control/clipboard.ts
Normal file
55
blocksuite/framework/std/src/event/control/clipboard.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { UIEventState, UIEventStateContext } from '../base.js';
|
||||
import type { UIEventDispatcher } from '../dispatcher.js';
|
||||
import { ClipboardEventState } from '../state/clipboard.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
|
||||
export class ClipboardControl {
|
||||
private readonly _copy = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'copy',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _cut = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'cut',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _paste = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'paste',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {}
|
||||
|
||||
private _createContext(event: Event, clipboardState: ClipboardEventState) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
}),
|
||||
clipboardState
|
||||
);
|
||||
}
|
||||
|
||||
listen() {
|
||||
this._dispatcher.disposables.addFromEvent(document, 'cut', this._cut);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'copy', this._copy);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'paste', this._paste);
|
||||
}
|
||||
}
|
||||
145
blocksuite/framework/std/src/event/control/keyboard.ts
Normal file
145
blocksuite/framework/std/src/event/control/keyboard.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_ANDROID, IS_MAC } from '@blocksuite/global/env';
|
||||
|
||||
import {
|
||||
type UIEventHandler,
|
||||
UIEventState,
|
||||
UIEventStateContext,
|
||||
} from '../base.js';
|
||||
import type { EventOptions, UIEventDispatcher } from '../dispatcher.js';
|
||||
import { androidBindKeymapPatch, bindKeymap } from '../keymap.js';
|
||||
import { KeyboardEventState } from '../state/index.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
|
||||
export class KeyboardControl {
|
||||
private readonly _down = (event: KeyboardEvent) => {
|
||||
if (!this._shouldTrigger(event)) {
|
||||
return;
|
||||
}
|
||||
const keyboardEventState = new KeyboardEventState({
|
||||
event,
|
||||
composing: this.composition,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'keyDown',
|
||||
this._createContext(event, keyboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _shouldTrigger = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return false;
|
||||
}
|
||||
const mod = IS_MAC ? event.metaKey : event.ctrlKey;
|
||||
if (
|
||||
['c', 'v', 'x'].includes(event.key) &&
|
||||
mod &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private readonly _up = (event: KeyboardEvent) => {
|
||||
if (!this._shouldTrigger(event)) {
|
||||
return;
|
||||
}
|
||||
const keyboardEventState = new KeyboardEventState({
|
||||
event,
|
||||
composing: this.composition,
|
||||
});
|
||||
|
||||
this._dispatcher.run(
|
||||
'keyUp',
|
||||
this._createContext(event, keyboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private composition = false;
|
||||
|
||||
private readonly _press = (event: KeyboardEvent) => {
|
||||
if (!this._shouldTrigger(event)) {
|
||||
return;
|
||||
}
|
||||
const keyboardEventState = new KeyboardEventState({
|
||||
event,
|
||||
composing: this.composition,
|
||||
});
|
||||
|
||||
this._dispatcher.run(
|
||||
'keyPress',
|
||||
this._createContext(event, keyboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {}
|
||||
|
||||
private _createContext(event: Event, keyboardState: KeyboardEventState) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
}),
|
||||
keyboardState
|
||||
);
|
||||
}
|
||||
|
||||
bindHotkey(keymap: Record<string, UIEventHandler>, options?: EventOptions) {
|
||||
const disposables = new DisposableGroup();
|
||||
if (IS_ANDROID) {
|
||||
disposables.add(
|
||||
this._dispatcher.add('beforeInput', ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = androidBindKeymapPatch(keymap);
|
||||
return binding(ctx);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
this._dispatcher.add(
|
||||
'keyDown',
|
||||
ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = bindKeymap(keymap);
|
||||
return binding(ctx);
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
return () => disposables.dispose();
|
||||
}
|
||||
|
||||
listen() {
|
||||
this._dispatcher.disposables.addFromEvent(document, 'keydown', this._down);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'keyup', this._up);
|
||||
this._dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'keypress',
|
||||
this._press
|
||||
);
|
||||
this._dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'compositionstart',
|
||||
() => {
|
||||
this.composition = true;
|
||||
},
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
this._dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'compositionend',
|
||||
() => {
|
||||
this.composition = false;
|
||||
},
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
626
blocksuite/framework/std/src/event/control/pointer.ts
Normal file
626
blocksuite/framework/std/src/event/control/pointer.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import { IS_IPAD } from '@blocksuite/global/env';
|
||||
import { Vec } from '@blocksuite/global/gfx';
|
||||
import { nextTick } from '@blocksuite/global/utils';
|
||||
|
||||
import { UIEventState, UIEventStateContext } from '../base.js';
|
||||
import type { UIEventDispatcher } from '../dispatcher.js';
|
||||
import {
|
||||
DndEventState,
|
||||
MultiPointerEventState,
|
||||
PointerEventState,
|
||||
} from '../state/index.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
import { isFarEnough } from '../utils.js';
|
||||
|
||||
type PointerId = typeof PointerEvent.prototype.pointerId;
|
||||
|
||||
function createContext(
|
||||
event: Event,
|
||||
state: PointerEventState | MultiPointerEventState
|
||||
) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Target,
|
||||
}),
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 1000;
|
||||
|
||||
abstract class PointerControllerBase {
|
||||
constructor(
|
||||
protected _dispatcher: UIEventDispatcher,
|
||||
protected _getRect: () => DOMRect
|
||||
) {}
|
||||
|
||||
abstract listen(): void;
|
||||
}
|
||||
|
||||
class PointerEventForward extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
|
||||
const pointerState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
this._startStates.set(pointerId, pointerState);
|
||||
this._lastStates.set(pointerId, pointerState);
|
||||
this._dispatcher.run('pointerDown', createContext(event, pointerState));
|
||||
};
|
||||
|
||||
private readonly _lastStates = new Map<PointerId, PointerEventState>();
|
||||
|
||||
private readonly _move = (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
|
||||
const start = this._startStates.get(pointerId) ?? null;
|
||||
const last = this._lastStates.get(pointerId) ?? null;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start?.x ?? -Infinity,
|
||||
startY: start?.y ?? -Infinity,
|
||||
last,
|
||||
});
|
||||
this._lastStates.set(pointerId, state);
|
||||
|
||||
this._dispatcher.run('pointerMove', createContext(event, state));
|
||||
};
|
||||
|
||||
private readonly _startStates = new Map<PointerId, PointerEventState>();
|
||||
|
||||
private readonly _upOrOut = (up: boolean) => (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
|
||||
const start = this._startStates.get(pointerId) ?? null;
|
||||
const last = this._lastStates.get(pointerId) ?? null;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start?.x ?? -Infinity,
|
||||
startY: start?.y ?? -Infinity,
|
||||
last,
|
||||
});
|
||||
|
||||
this._startStates.delete(pointerId);
|
||||
this._lastStates.delete(pointerId);
|
||||
|
||||
this._dispatcher.run(
|
||||
up ? 'pointerUp' : 'pointerOut',
|
||||
createContext(event, state)
|
||||
);
|
||||
};
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
disposables.addFromEvent(host, 'pointermove', this._move);
|
||||
disposables.addFromEvent(host, 'pointerup', this._upOrOut(true));
|
||||
disposables.addFromEvent(host, 'pointerout', this._upOrOut(false));
|
||||
}
|
||||
}
|
||||
|
||||
class ClickController extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
// disable for secondary pointer
|
||||
if (event.isPrimary === false) return;
|
||||
|
||||
if (
|
||||
this._downPointerState &&
|
||||
event.pointerId === this._downPointerState.raw.pointerId &&
|
||||
event.timeStamp - this._downPointerState.raw.timeStamp < 500 &&
|
||||
!isFarEnough(event, this._downPointerState.raw)
|
||||
) {
|
||||
this._pointerDownCount++;
|
||||
} else {
|
||||
this._pointerDownCount = 1;
|
||||
}
|
||||
|
||||
this._downPointerState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
};
|
||||
|
||||
private _downPointerState: PointerEventState | null = null;
|
||||
|
||||
private _pointerDownCount = 0;
|
||||
|
||||
private readonly _up = (event: PointerEvent) => {
|
||||
if (!this._downPointerState) return;
|
||||
|
||||
if (isFarEnough(this._downPointerState.raw, event)) {
|
||||
this._pointerDownCount = 0;
|
||||
this._downPointerState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
const context = createContext(event, state);
|
||||
|
||||
const run = () => {
|
||||
this._dispatcher.run('click', context);
|
||||
if (this._pointerDownCount === 2) {
|
||||
this._dispatcher.run('doubleClick', context);
|
||||
}
|
||||
if (this._pointerDownCount === 3) {
|
||||
this._dispatcher.run('tripleClick', context);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
};
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
disposables.addFromEvent(host, 'pointerup', this._up);
|
||||
}
|
||||
}
|
||||
|
||||
class DragController extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
if (this._nativeDragging) return;
|
||||
|
||||
if (!event.isPrimary) {
|
||||
if (this._dragging && this._lastPointerState) {
|
||||
this._up(this._lastPointerState.raw);
|
||||
}
|
||||
this._reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
this._startPointerState = pointerState;
|
||||
|
||||
this._dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'pointermove',
|
||||
this._move
|
||||
);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'pointerup', this._up);
|
||||
};
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _lastPointerState: PointerEventState | null = null;
|
||||
|
||||
private readonly _move = (event: PointerEvent) => {
|
||||
if (
|
||||
this._startPointerState === null ||
|
||||
this._startPointerState.raw.pointerId !== event.pointerId
|
||||
)
|
||||
return;
|
||||
|
||||
const start = this._startPointerState;
|
||||
const last = this._lastPointerState ?? start;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start.x,
|
||||
startY: start.y,
|
||||
last,
|
||||
});
|
||||
|
||||
this._lastPointerState = state;
|
||||
|
||||
if (
|
||||
!this._nativeDragging &&
|
||||
!this._dragging &&
|
||||
isFarEnough(event, this._startPointerState.raw)
|
||||
) {
|
||||
this._dragging = true;
|
||||
this._dispatcher.run('dragStart', createContext(event, start));
|
||||
}
|
||||
|
||||
if (this._dragging) {
|
||||
this._dispatcher.run('dragMove', createContext(event, state));
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _nativeDragEnd = (event: DragEvent) => {
|
||||
this._nativeDragging = false;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragEnd',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private _nativeDragging = false;
|
||||
|
||||
private readonly _nativeDragMove = (event: DragEvent) => {
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragMove',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragStart = (event: DragEvent) => {
|
||||
this._reset();
|
||||
this._nativeDragging = true;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragStart',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragOver = (event: DragEvent) => {
|
||||
// prevent default to allow drop in editor
|
||||
event.preventDefault();
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragOver',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragLeave = (event: DragEvent) => {
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragLeave',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDrop = (event: DragEvent) => {
|
||||
this._reset();
|
||||
this._nativeDragging = false;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDrop',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _reset = () => {
|
||||
this._dragging = false;
|
||||
this._startPointerState = null;
|
||||
this._lastPointerState = null;
|
||||
|
||||
document.removeEventListener('pointermove', this._move);
|
||||
document.removeEventListener('pointerup', this._up);
|
||||
};
|
||||
|
||||
private _startPointerState: PointerEventState | null = null;
|
||||
|
||||
private readonly _up = (event: PointerEvent) => {
|
||||
if (
|
||||
!this._startPointerState ||
|
||||
this._startPointerState.raw.pointerId !== event.pointerId
|
||||
)
|
||||
return;
|
||||
|
||||
const start = this._startPointerState;
|
||||
const last = this._lastPointerState;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start.x,
|
||||
startY: start.y,
|
||||
last,
|
||||
});
|
||||
|
||||
if (this._dragging) {
|
||||
this._dispatcher.run('dragEnd', createContext(event, state));
|
||||
}
|
||||
|
||||
this._reset();
|
||||
};
|
||||
|
||||
// https://mikepk.com/2020/10/iOS-safari-scribble-bug/
|
||||
private _applyScribblePatch() {
|
||||
if (!IS_IPAD) return;
|
||||
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'touchmove', (event: TouchEvent) => {
|
||||
if (
|
||||
this._dragging &&
|
||||
this._startPointerState &&
|
||||
this._startPointerState.raw.pointerType === 'pen'
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _createContext(event: Event, dndState: DndEventState) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Target,
|
||||
}),
|
||||
dndState
|
||||
);
|
||||
}
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
this._applyScribblePatch();
|
||||
|
||||
disposables.add(
|
||||
host.std.dnd.monitor({
|
||||
onDragStart: () => {
|
||||
this._nativeDragging = true;
|
||||
},
|
||||
onDrop: () => {
|
||||
this._nativeDragging = false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
disposables.addFromEvent(host, 'dragstart', this._nativeDragStart);
|
||||
disposables.addFromEvent(host, 'dragend', this._nativeDragEnd);
|
||||
disposables.addFromEvent(host, 'drag', this._nativeDragMove);
|
||||
disposables.addFromEvent(host, 'drop', this._nativeDrop);
|
||||
disposables.addFromEvent(host, 'dragover', this._nativeDragOver);
|
||||
disposables.addFromEvent(host, 'dragleave', this._nativeDragLeave);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DualDragControllerBase extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
// Another pointer down
|
||||
if (
|
||||
this._startPointerStates.primary !== null &&
|
||||
this._startPointerStates.secondary !== null
|
||||
) {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
if (this._startPointerStates.primary === null && !event.isPrimary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
|
||||
if (event.isPrimary) {
|
||||
this._startPointerStates.primary = state;
|
||||
} else {
|
||||
this._startPointerStates.secondary = state;
|
||||
}
|
||||
};
|
||||
|
||||
private _lastPointerStates: {
|
||||
primary: PointerEventState | null;
|
||||
secondary: PointerEventState | null;
|
||||
} = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
|
||||
private readonly _move = (event: PointerEvent) => {
|
||||
if (
|
||||
this._startPointerStates.primary === null ||
|
||||
this._startPointerStates.secondary === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isPrimary } = event;
|
||||
const startPrimaryState = this._startPointerStates.primary;
|
||||
let lastPrimaryState = this._lastPointerStates.primary;
|
||||
|
||||
const startSecondaryState = this._startPointerStates.secondary;
|
||||
let lastSecondaryState = this._lastPointerStates.secondary;
|
||||
|
||||
if (isPrimary) {
|
||||
lastPrimaryState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: startPrimaryState.x,
|
||||
startY: startPrimaryState.y,
|
||||
last: lastPrimaryState,
|
||||
});
|
||||
} else {
|
||||
lastSecondaryState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: startSecondaryState.x,
|
||||
startY: startSecondaryState.y,
|
||||
last: lastSecondaryState,
|
||||
});
|
||||
}
|
||||
|
||||
const multiPointerState = new MultiPointerEventState(event, [
|
||||
lastPrimaryState ?? startPrimaryState,
|
||||
lastSecondaryState ?? startSecondaryState,
|
||||
]);
|
||||
|
||||
this._handleMove(event, multiPointerState);
|
||||
|
||||
this._lastPointerStates = {
|
||||
primary: lastPrimaryState,
|
||||
secondary: lastSecondaryState,
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _reset = () => {
|
||||
this._startPointerStates = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
this._lastPointerStates = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
};
|
||||
|
||||
private _startPointerStates: {
|
||||
primary: PointerEventState | null;
|
||||
secondary: PointerEventState | null;
|
||||
} = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
|
||||
private readonly _upOrOut = (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
if (
|
||||
pointerId === this._startPointerStates.primary?.raw.pointerId ||
|
||||
pointerId === this._startPointerStates.secondary?.raw.pointerId
|
||||
) {
|
||||
this._reset();
|
||||
}
|
||||
};
|
||||
|
||||
abstract _handleMove(
|
||||
event: PointerEvent,
|
||||
state: MultiPointerEventState
|
||||
): void;
|
||||
|
||||
override listen(): void {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
disposables.addFromEvent(host, 'pointermove', this._move);
|
||||
disposables.addFromEvent(host, 'pointerup', this._upOrOut);
|
||||
disposables.addFromEvent(host, 'pointerout', this._upOrOut);
|
||||
}
|
||||
}
|
||||
|
||||
class PinchController extends DualDragControllerBase {
|
||||
override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
|
||||
const deltaFirstPointer = state.pointers[0].delta;
|
||||
const deltaSecondPointer = state.pointers[1].delta;
|
||||
|
||||
const deltaFirstPointerVec = Vec.toVec(deltaFirstPointer);
|
||||
const deltaSecondPointerVec = Vec.toVec(deltaSecondPointer);
|
||||
|
||||
const deltaFirstPointerValue = Vec.len(deltaFirstPointerVec);
|
||||
const deltaSecondPointerValue = Vec.len(deltaSecondPointerVec);
|
||||
|
||||
const deltaDotProduct = Vec.dpr(
|
||||
deltaFirstPointerVec,
|
||||
deltaSecondPointerVec
|
||||
);
|
||||
|
||||
const deltaValueThreshold = 0.1;
|
||||
|
||||
// the changes of distance between two pointers is not far enough
|
||||
if (
|
||||
!isFarEnough(deltaFirstPointer, deltaSecondPointer) ||
|
||||
deltaDotProduct > 0 ||
|
||||
deltaFirstPointerValue < deltaValueThreshold ||
|
||||
deltaSecondPointerValue < deltaValueThreshold
|
||||
)
|
||||
return;
|
||||
|
||||
this._dispatcher.run('pinch', createContext(event, state));
|
||||
}
|
||||
}
|
||||
|
||||
class PanController extends DualDragControllerBase {
|
||||
override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
|
||||
const deltaFirstPointer = state.pointers[0].delta;
|
||||
const deltaSecondPointer = state.pointers[1].delta;
|
||||
|
||||
const deltaDotProduct = Vec.dpr(
|
||||
Vec.toVec(deltaFirstPointer),
|
||||
Vec.toVec(deltaSecondPointer)
|
||||
);
|
||||
|
||||
// the center move distance is not far enough
|
||||
if (
|
||||
!isFarEnough(deltaFirstPointer, deltaSecondPointer) &&
|
||||
deltaDotProduct < 0
|
||||
)
|
||||
return;
|
||||
|
||||
this._dispatcher.run('pan', createContext(event, state));
|
||||
}
|
||||
}
|
||||
|
||||
export class PointerControl {
|
||||
private _cachedRect: DOMRect | null = null;
|
||||
|
||||
private readonly _getRect = () => {
|
||||
if (this._cachedRect === null) {
|
||||
this._updateRect();
|
||||
}
|
||||
return this._cachedRect as DOMRect;
|
||||
};
|
||||
|
||||
// XXX: polling is used instead of MutationObserver
|
||||
// due to potential performance issues
|
||||
private _pollingInterval: number | null = null;
|
||||
|
||||
private readonly controllers: PointerControllerBase[];
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {
|
||||
this.controllers = [
|
||||
new PointerEventForward(_dispatcher, this._getRect),
|
||||
new ClickController(_dispatcher, this._getRect),
|
||||
new DragController(_dispatcher, this._getRect),
|
||||
new PanController(_dispatcher, this._getRect),
|
||||
new PinchController(_dispatcher, this._getRect),
|
||||
];
|
||||
}
|
||||
|
||||
private _startPolling() {
|
||||
const poll = () => {
|
||||
nextTick()
|
||||
.then(() => this._updateRect())
|
||||
.catch(console.error);
|
||||
};
|
||||
this._pollingInterval = window.setInterval(poll, POLL_INTERVAL);
|
||||
poll();
|
||||
}
|
||||
|
||||
protected _updateRect() {
|
||||
if (!this._dispatcher.host) return;
|
||||
this._cachedRect = this._dispatcher.host.getBoundingClientRect();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._pollingInterval !== null) {
|
||||
clearInterval(this._pollingInterval);
|
||||
this._pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
listen() {
|
||||
this._startPolling();
|
||||
this.controllers.forEach(controller => controller.listen());
|
||||
}
|
||||
}
|
||||
156
blocksuite/framework/std/src/event/control/range.ts
Normal file
156
blocksuite/framework/std/src/event/control/range.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { BlockComponent } from '../../view/index.js';
|
||||
import { UIEventState, UIEventStateContext } from '../base.js';
|
||||
import type {
|
||||
EventHandlerRunner,
|
||||
EventName,
|
||||
UIEventDispatcher,
|
||||
} from '../dispatcher.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
|
||||
export class RangeControl {
|
||||
private readonly _buildScope = (eventName: EventName) => {
|
||||
let scope: EventHandlerRunner[] | undefined;
|
||||
const selection = document.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
scope = this._buildEventScopeByNativeRange(eventName, range);
|
||||
this._prev = range;
|
||||
} else if (this._prev !== null) {
|
||||
scope = this._buildEventScopeByNativeRange(eventName, this._prev);
|
||||
this._prev = null;
|
||||
}
|
||||
|
||||
return scope;
|
||||
};
|
||||
|
||||
private readonly _compositionEnd = (event: Event) => {
|
||||
const scope = this._buildScope('compositionEnd');
|
||||
|
||||
this._dispatcher.run('compositionEnd', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
private readonly _compositionStart = (event: Event) => {
|
||||
const scope = this._buildScope('compositionStart');
|
||||
|
||||
this._dispatcher.run('compositionStart', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
private readonly _compositionUpdate = (event: Event) => {
|
||||
const scope = this._buildScope('compositionUpdate');
|
||||
|
||||
this._dispatcher.run(
|
||||
'compositionUpdate',
|
||||
this._createContext(event),
|
||||
scope
|
||||
);
|
||||
};
|
||||
|
||||
private _prev: Range | null = null;
|
||||
|
||||
private readonly _selectionChange = (event: Event) => {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (!selection.containsNode(this._dispatcher.host, true)) return;
|
||||
if (selection.containsNode(this._dispatcher.host)) return;
|
||||
|
||||
const scope = this._buildScope('selectionChange');
|
||||
|
||||
this._dispatcher.run('selectionChange', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {}
|
||||
|
||||
private _buildEventScopeByNativeRange(name: EventName, range: Range) {
|
||||
const blockIds = this._findBlockComponentPath(range);
|
||||
|
||||
return this._dispatcher.buildEventScope(name, blockIds);
|
||||
}
|
||||
|
||||
private _createContext(event: Event) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _findBlockComponentPath(range: Range): string[] {
|
||||
const start = range.startContainer;
|
||||
const end = range.endContainer;
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
const getBlockView = (node: Node): BlockComponent | null => {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
// TODO(mirone/#6534): find a better way to get block element from a node
|
||||
return el?.closest<BlockComponent>('[data-block-id]') ?? null;
|
||||
};
|
||||
if (ancestor.nodeType === Node.TEXT_NODE) {
|
||||
const leaf = getBlockView(ancestor);
|
||||
if (leaf) {
|
||||
return [leaf.blockId];
|
||||
}
|
||||
}
|
||||
const nodes = new Set<Node>();
|
||||
|
||||
let startRecorded = false;
|
||||
const dfsDOMSearch = (current: Node | null, ancestor: Node) => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (current === ancestor) {
|
||||
return;
|
||||
}
|
||||
if (current === end) {
|
||||
nodes.add(current);
|
||||
startRecorded = false;
|
||||
return;
|
||||
}
|
||||
if (current === start) {
|
||||
startRecorded = true;
|
||||
}
|
||||
// oxlint-disable-next-line sonarjs/no-collapsible-if
|
||||
if (startRecorded) {
|
||||
if (
|
||||
current.nodeType === Node.TEXT_NODE ||
|
||||
current.nodeType === Node.ELEMENT_NODE
|
||||
) {
|
||||
nodes.add(current);
|
||||
}
|
||||
}
|
||||
dfsDOMSearch(current.firstChild, ancestor);
|
||||
dfsDOMSearch(current.nextSibling, ancestor);
|
||||
};
|
||||
dfsDOMSearch(ancestor.firstChild, ancestor);
|
||||
|
||||
const blocks = new Set<string>();
|
||||
nodes.forEach(node => {
|
||||
const blockView = getBlockView(node);
|
||||
if (!blockView) {
|
||||
return;
|
||||
}
|
||||
if (blocks.has(blockView.blockId)) {
|
||||
return;
|
||||
}
|
||||
blocks.add(blockView.blockId);
|
||||
});
|
||||
return Array.from(blocks);
|
||||
}
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(
|
||||
document,
|
||||
'selectionchange',
|
||||
this._selectionChange
|
||||
);
|
||||
disposables.addFromEvent(host, 'compositionstart', this._compositionStart);
|
||||
disposables.addFromEvent(host, 'compositionend', this._compositionEnd);
|
||||
disposables.addFromEvent(
|
||||
host,
|
||||
'compositionupdate',
|
||||
this._compositionUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
458
blocksuite/framework/std/src/event/dispatcher.ts
Normal file
458
blocksuite/framework/std/src/event/dispatcher.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { KeymapIdentifier } from '../identifier.js';
|
||||
import { shouldDeactivateEditorOnFocusOut } from '../inline/range/active.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
import { type BlockComponent, EditorHost } from '../view/index.js';
|
||||
import {
|
||||
type UIEventHandler,
|
||||
UIEventState,
|
||||
UIEventStateContext,
|
||||
} from './base.js';
|
||||
import { ClipboardControl } from './control/clipboard.js';
|
||||
import { KeyboardControl } from './control/keyboard.js';
|
||||
import { PointerControl } from './control/pointer.js';
|
||||
import { RangeControl } from './control/range.js';
|
||||
import { EventScopeSourceType, EventSourceState } from './state/source.js';
|
||||
import { toLowerCase } from './utils.js';
|
||||
|
||||
const bypassEventNames = [
|
||||
'beforeInput',
|
||||
|
||||
'blur',
|
||||
'focus',
|
||||
'contextMenu',
|
||||
'wheel',
|
||||
] as const;
|
||||
|
||||
const eventNames = [
|
||||
'click',
|
||||
'doubleClick',
|
||||
'tripleClick',
|
||||
|
||||
'pointerDown',
|
||||
'pointerMove',
|
||||
'pointerUp',
|
||||
'pointerOut',
|
||||
|
||||
'dragStart',
|
||||
'dragMove',
|
||||
'dragEnd',
|
||||
|
||||
'pinch',
|
||||
'pan',
|
||||
|
||||
'keyDown',
|
||||
'keyUp',
|
||||
'keyPress',
|
||||
|
||||
'selectionChange',
|
||||
'compositionStart',
|
||||
'compositionUpdate',
|
||||
'compositionEnd',
|
||||
|
||||
'cut',
|
||||
'copy',
|
||||
'paste',
|
||||
|
||||
'nativeDragStart',
|
||||
'nativeDragMove',
|
||||
'nativeDragEnd',
|
||||
'nativeDrop',
|
||||
'nativeDragOver',
|
||||
'nativeDragLeave',
|
||||
|
||||
...bypassEventNames,
|
||||
] as const;
|
||||
|
||||
export type EventName = (typeof eventNames)[number];
|
||||
export type EventOptions = {
|
||||
flavour?: string;
|
||||
blockId?: string;
|
||||
};
|
||||
export type EventHandlerRunner = {
|
||||
fn: UIEventHandler;
|
||||
flavour?: string;
|
||||
blockId?: string;
|
||||
};
|
||||
|
||||
const syntheticEventNames = new Set(['click', 'doubleClick', 'tripleClick']);
|
||||
|
||||
export class UIEventDispatcher extends LifeCycleWatcher {
|
||||
private static _activeDispatcher: UIEventDispatcher | null = null;
|
||||
|
||||
static override readonly key = 'UIEventDispatcher';
|
||||
|
||||
private readonly _active = signal(false);
|
||||
|
||||
private readonly _clipboardControl: ClipboardControl;
|
||||
|
||||
private _handlersMap = Object.fromEntries(
|
||||
eventNames.map((name): [EventName, Array<EventHandlerRunner>] => [name, []])
|
||||
) as Record<EventName, Array<EventHandlerRunner>>;
|
||||
|
||||
private readonly _keyboardControl: KeyboardControl;
|
||||
|
||||
private readonly _pointerControl: PointerControl;
|
||||
|
||||
private readonly _rangeControl: RangeControl;
|
||||
|
||||
bindHotkey = (...args: Parameters<KeyboardControl['bindHotkey']>) =>
|
||||
this._keyboardControl.bindHotkey(...args);
|
||||
|
||||
disposables = new DisposableGroup();
|
||||
|
||||
private get _currentSelections() {
|
||||
return this.std.selection.value;
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this._active.peek();
|
||||
}
|
||||
|
||||
get active$() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
super(std);
|
||||
this._pointerControl = new PointerControl(this);
|
||||
this._keyboardControl = new KeyboardControl(this);
|
||||
this._rangeControl = new RangeControl(this);
|
||||
this._clipboardControl = new ClipboardControl(this);
|
||||
this.disposables.add(this._pointerControl);
|
||||
}
|
||||
|
||||
private _bindEvents() {
|
||||
bypassEventNames.forEach(eventName => {
|
||||
this.disposables.addFromEvent(
|
||||
this.host,
|
||||
toLowerCase(eventName),
|
||||
event => {
|
||||
this.run(
|
||||
eventName,
|
||||
UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
eventName === 'wheel'
|
||||
? {
|
||||
passive: false,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
});
|
||||
|
||||
this._pointerControl.listen();
|
||||
this._keyboardControl.listen();
|
||||
this._rangeControl.listen();
|
||||
this._clipboardControl.listen();
|
||||
|
||||
let _dragging = false;
|
||||
this.disposables.addFromEvent(this.host, 'pointerdown', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'pointerup', () => {
|
||||
_dragging = false;
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'click', () => {
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'focusin', () => {
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(document, 'focusout', e => {
|
||||
if (shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget)) {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'blur', e => {
|
||||
if (_dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!e.relatedTarget ||
|
||||
shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget)
|
||||
) {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragover', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragenter', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragstart', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragend', () => {
|
||||
_dragging = false;
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'drop', () => {
|
||||
_dragging = false;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'pointerenter', () => {
|
||||
if (this._isActiveElementOutsideHost()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'pointerleave', () => {
|
||||
if (
|
||||
(document.activeElement &&
|
||||
this.host.contains(document.activeElement)) ||
|
||||
_dragging
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setActive(false);
|
||||
});
|
||||
// When the document is hidden, the event dispatcher should be inactive
|
||||
this.disposables.addFromEvent(document, 'visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
// When the selection is outside the host, the event dispatcher should be inactive
|
||||
this.disposables.addFromEvent(document, 'selectionchange', () => {
|
||||
const sel = document.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const { anchorNode, focusNode } = sel;
|
||||
if (
|
||||
(anchorNode && !this.host.contains(anchorNode)) ||
|
||||
(focusNode && !this.host.contains(focusNode))
|
||||
) {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _buildEventScopeBySelection(name: EventName) {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
const selections = this._currentSelections;
|
||||
const ids = selections.map(selection => selection.blockId);
|
||||
|
||||
return this.buildEventScope(name, ids);
|
||||
}
|
||||
|
||||
private _buildEventScopeByTarget(name: EventName, target: Node) {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
// TODO(mirone/#6534): find a better way to get block element from a node
|
||||
const el = target instanceof Element ? target : target.parentElement;
|
||||
const block = el?.closest<BlockComponent>('[data-block-id]');
|
||||
|
||||
const blockId = block?.blockId;
|
||||
if (!blockId) {
|
||||
return this._buildEventScopeBySelection(name);
|
||||
}
|
||||
|
||||
return this.buildEventScope(name, [blockId]);
|
||||
}
|
||||
|
||||
private _getDeepActiveElement(): Element | null {
|
||||
let active = document.activeElement;
|
||||
while (active && active.shadowRoot && active.shadowRoot.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
private _getEventScope(name: EventName, state: EventSourceState) {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
let output: EventHandlerRunner[] | undefined;
|
||||
|
||||
switch (state.sourceType) {
|
||||
case EventScopeSourceType.Selection: {
|
||||
output = this._buildEventScopeBySelection(name);
|
||||
break;
|
||||
}
|
||||
case EventScopeSourceType.Target: {
|
||||
output = this._buildEventScopeByTarget(
|
||||
name,
|
||||
state.event.target as Node
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EventDispatcherError,
|
||||
`Unknown event scope source: ${state.sourceType}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private _isActiveElementOutsideHost(): boolean {
|
||||
const activeElement = this._getDeepActiveElement();
|
||||
return (
|
||||
activeElement !== null &&
|
||||
this._isEditableElementActive(activeElement) &&
|
||||
!this.host.contains(activeElement)
|
||||
);
|
||||
}
|
||||
|
||||
private _isEditableElementActive(element: Element | null): boolean {
|
||||
if (!element) return false;
|
||||
return (
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLTextAreaElement ||
|
||||
(element instanceof EditorHost && !element.store.readonly) ||
|
||||
(element as HTMLElement).isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
private _setActive(active: boolean) {
|
||||
if (active) {
|
||||
if (UIEventDispatcher._activeDispatcher !== this) {
|
||||
if (UIEventDispatcher._activeDispatcher) {
|
||||
UIEventDispatcher._activeDispatcher._active.value = false;
|
||||
}
|
||||
UIEventDispatcher._activeDispatcher = this;
|
||||
}
|
||||
this._active.value = true;
|
||||
} else {
|
||||
if (UIEventDispatcher._activeDispatcher === this) {
|
||||
UIEventDispatcher._activeDispatcher = null;
|
||||
}
|
||||
this._active.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
set active(active: boolean) {
|
||||
if (active === this._active.peek()) return;
|
||||
this._setActive(active);
|
||||
}
|
||||
|
||||
add(name: EventName, handler: UIEventHandler, options?: EventOptions) {
|
||||
const runner: EventHandlerRunner = {
|
||||
fn: handler,
|
||||
flavour: options?.flavour,
|
||||
blockId: options?.blockId,
|
||||
};
|
||||
this._handlersMap[name].unshift(runner);
|
||||
return () => {
|
||||
if (this._handlersMap[name].includes(runner)) {
|
||||
this._handlersMap[name] = this._handlersMap[name].filter(
|
||||
x => x !== runner
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
buildEventScope(
|
||||
name: EventName,
|
||||
blocks: string[]
|
||||
): EventHandlerRunner[] | undefined {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
const globalEvents = handlers.filter(
|
||||
handler => handler.flavour === undefined && handler.blockId === undefined
|
||||
);
|
||||
|
||||
let blockIds: string[] = blocks;
|
||||
const events: EventHandlerRunner[] = [];
|
||||
const flavourSeen: Record<string, boolean> = {};
|
||||
while (blockIds.length > 0) {
|
||||
const idHandlers = handlers.filter(
|
||||
handler => handler.blockId && blockIds.includes(handler.blockId)
|
||||
);
|
||||
|
||||
const flavourHandlers = blockIds
|
||||
.map(blockId => this.std.store.getBlock(blockId)?.flavour)
|
||||
.filter((flavour): flavour is string => {
|
||||
if (!flavour) return false;
|
||||
if (flavourSeen[flavour]) return false;
|
||||
flavourSeen[flavour] = true;
|
||||
return true;
|
||||
})
|
||||
.flatMap(flavour => {
|
||||
return handlers.filter(handler => handler.flavour === flavour);
|
||||
});
|
||||
|
||||
events.push(...idHandlers, ...flavourHandlers);
|
||||
blockIds = blockIds
|
||||
.map(blockId => {
|
||||
const parent = this.std.store.getParent(blockId);
|
||||
return parent?.id;
|
||||
})
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
|
||||
return events.concat(globalEvents);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
if (this.disposables.disposed) {
|
||||
this.disposables = new DisposableGroup();
|
||||
}
|
||||
this._bindEvents();
|
||||
|
||||
const std = this.std;
|
||||
this.std.provider
|
||||
.getAll(KeymapIdentifier)
|
||||
.forEach(({ getter, options }) => {
|
||||
this.bindHotkey(getter(std), options);
|
||||
});
|
||||
}
|
||||
|
||||
run(
|
||||
name: EventName,
|
||||
context: UIEventStateContext,
|
||||
runners?: EventHandlerRunner[]
|
||||
) {
|
||||
if (!this.active) return;
|
||||
|
||||
const sourceState = context.get('sourceState');
|
||||
if (!runners) {
|
||||
runners = this._getEventScope(name, sourceState);
|
||||
if (!runners) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const runner of runners) {
|
||||
const { fn } = runner;
|
||||
const result = fn(context);
|
||||
if (result) {
|
||||
// Only stop propagation for non-synthetic events
|
||||
if (!syntheticEventNames.has(name)) {
|
||||
context.get('defaultState').event.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
4
blocksuite/framework/std/src/event/index.ts
Normal file
4
blocksuite/framework/std/src/event/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './base.js';
|
||||
export * from './dispatcher.js';
|
||||
export * from './keymap.js';
|
||||
export * from './state/index.js';
|
||||
165
blocksuite/framework/std/src/event/keymap.ts
Normal file
165
blocksuite/framework/std/src/event/keymap.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { base, keyName } from 'w3c-keyname';
|
||||
|
||||
import type { UIEventHandler } from './base.js';
|
||||
import { KeyboardEventState } from './state/index.js';
|
||||
|
||||
function normalizeKeyName(name: string) {
|
||||
const parts = name.split(/-(?!$)/);
|
||||
let result = parts.at(-1);
|
||||
if (result === 'Space') {
|
||||
result = ' ';
|
||||
}
|
||||
let alt, ctrl, shift, meta;
|
||||
parts.slice(0, -1).forEach(mod => {
|
||||
if (/^(cmd|meta|m)$/i.test(mod)) {
|
||||
meta = true;
|
||||
return;
|
||||
}
|
||||
if (/^a(lt)?$/i.test(mod)) {
|
||||
alt = true;
|
||||
return;
|
||||
}
|
||||
if (/^(c|ctrl|control)$/i.test(mod)) {
|
||||
ctrl = true;
|
||||
return;
|
||||
}
|
||||
if (/^s(hift)?$/i.test(mod)) {
|
||||
shift = true;
|
||||
return;
|
||||
}
|
||||
if (/^mod$/i.test(mod)) {
|
||||
if (IS_MAC) {
|
||||
meta = true;
|
||||
} else {
|
||||
ctrl = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EventDispatcherError,
|
||||
'Unrecognized modifier name: ' + mod
|
||||
);
|
||||
});
|
||||
if (alt) result = 'Alt-' + result;
|
||||
if (ctrl) result = 'Ctrl-' + result;
|
||||
if (meta) result = 'Meta-' + result;
|
||||
if (shift) result = 'Shift-' + result;
|
||||
return result as string;
|
||||
}
|
||||
|
||||
function modifiers(name: string, event: KeyboardEvent, shift = true) {
|
||||
if (event.altKey) name = 'Alt-' + name;
|
||||
if (event.ctrlKey) name = 'Ctrl-' + name;
|
||||
if (event.metaKey) name = 'Meta-' + name;
|
||||
if (shift && event.shiftKey) name = 'Shift-' + name;
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalize(map: Record<string, UIEventHandler>) {
|
||||
const copy: Record<string, UIEventHandler> = Object.create(null);
|
||||
for (const prop in map) copy[normalizeKeyName(prop)] = map[prop];
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function bindKeymap(
|
||||
bindings: Record<string, UIEventHandler>
|
||||
): UIEventHandler {
|
||||
const map = normalize(bindings);
|
||||
return ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
const event = state.raw;
|
||||
const name = keyName(event);
|
||||
const direct = map[modifiers(name, event)];
|
||||
if (direct && direct(ctx)) {
|
||||
return true;
|
||||
}
|
||||
if (name.length !== 1 || name === ' ') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
const noShift = map[modifiers(name, event, false)];
|
||||
if (noShift && noShift(ctx)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-standard keyboards, fallback to keyCode only when modifier keys are pressed.
|
||||
// Do NOT fallback when the key produces a non-ASCII character (e.g., Cyrillic 'х' on Russian keyboard),
|
||||
// because the user intends to type that character, not trigger a shortcut bound to the physical key.
|
||||
// See: https://github.com/toeverything/AFFiNE/issues/14059
|
||||
const hasModifier =
|
||||
event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
|
||||
const baseName = base[event.keyCode];
|
||||
const isSingleAscii = name.length === 1 && name.charCodeAt(0) <= 0x7e;
|
||||
const isAltInputChar =
|
||||
event.altKey && !event.ctrlKey && !event.metaKey && !isSingleAscii;
|
||||
// Keep supporting existing Alt+digit shortcuts (e.g. Alt-0/1/2 in edgeless)
|
||||
// while preventing Alt-based locale input characters from triggering letter shortcuts.
|
||||
const isDigitBaseKey =
|
||||
baseName != null && baseName.length === 1 && /[0-9]/.test(baseName);
|
||||
if (
|
||||
hasModifier &&
|
||||
baseName &&
|
||||
baseName !== name &&
|
||||
!(isAltInputChar && !isDigitBaseKey)
|
||||
) {
|
||||
const fromCode = map[modifiers(baseName, event)];
|
||||
if (fromCode && fromCode(ctx)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// In some IME of Android like, the keypress event dose not contain
|
||||
// the information about what key is pressed. See
|
||||
// https://stackoverflow.com/a/68188679
|
||||
// https://stackoverflow.com/a/66724830
|
||||
export function androidBindKeymapPatch(
|
||||
bindings: Record<string, UIEventHandler>
|
||||
): UIEventHandler {
|
||||
return ctx => {
|
||||
const event = ctx.get('defaultState').event;
|
||||
if (!(event instanceof InputEvent)) return;
|
||||
|
||||
const bindingName =
|
||||
event.inputType === 'deleteContentBackward'
|
||||
? 'Backspace'
|
||||
: event.inputType === 'deleteContentForward'
|
||||
? 'Delete'
|
||||
: event.inputType === 'insertParagraph'
|
||||
? 'Enter'
|
||||
: undefined;
|
||||
if (!bindingName || !(bindingName in bindings)) return false;
|
||||
|
||||
if (!ctx.has('keyboardState')) {
|
||||
const keyboardEvent = new KeyboardEvent('keydown', {
|
||||
key: bindingName,
|
||||
code: bindingName,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(keyboardEvent, 'isComposing', {
|
||||
configurable: true,
|
||||
value: event.isComposing,
|
||||
});
|
||||
ctx.add(
|
||||
new KeyboardEventState({
|
||||
event: keyboardEvent,
|
||||
composing: event.isComposing,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const handled = bindings[bindingName](ctx);
|
||||
if (handled || ctx.get('keyboardState').raw.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
}
|
||||
23
blocksuite/framework/std/src/event/state/clipboard.ts
Normal file
23
blocksuite/framework/std/src/event/state/clipboard.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type ClipboardEventStateOptions = {
|
||||
event: ClipboardEvent;
|
||||
};
|
||||
|
||||
export class ClipboardEventState extends UIEventState {
|
||||
raw: ClipboardEvent;
|
||||
|
||||
override type = 'clipboardState';
|
||||
|
||||
constructor({ event }: ClipboardEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
this.raw = event;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
clipboardState: ClipboardEventState;
|
||||
}
|
||||
}
|
||||
23
blocksuite/framework/std/src/event/state/dnd.ts
Normal file
23
blocksuite/framework/std/src/event/state/dnd.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type DndEventStateOptions = {
|
||||
event: DragEvent;
|
||||
};
|
||||
|
||||
export class DndEventState extends UIEventState {
|
||||
raw: DragEvent;
|
||||
|
||||
override type = 'dndState';
|
||||
|
||||
constructor({ event }: DndEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
this.raw = event;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
dndState: DndEventState;
|
||||
}
|
||||
}
|
||||
5
blocksuite/framework/std/src/event/state/index.ts
Normal file
5
blocksuite/framework/std/src/event/state/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './clipboard.js';
|
||||
export * from './dnd.js';
|
||||
export * from './keyboard.js';
|
||||
export * from './pointer.js';
|
||||
export * from './source.js';
|
||||
27
blocksuite/framework/std/src/event/state/keyboard.ts
Normal file
27
blocksuite/framework/std/src/event/state/keyboard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type KeyboardEventStateOptions = {
|
||||
event: KeyboardEvent;
|
||||
composing: boolean;
|
||||
};
|
||||
|
||||
export class KeyboardEventState extends UIEventState {
|
||||
composing: boolean;
|
||||
|
||||
raw: KeyboardEvent;
|
||||
|
||||
override type = 'keyboardState';
|
||||
|
||||
constructor({ event, composing }: KeyboardEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
this.raw = event;
|
||||
this.composing = composing;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
keyboardState: KeyboardEventState;
|
||||
}
|
||||
}
|
||||
83
blocksuite/framework/std/src/event/state/pointer.ts
Normal file
83
blocksuite/framework/std/src/event/state/pointer.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type PointerEventStateOptions = {
|
||||
event: PointerEvent;
|
||||
rect: DOMRect;
|
||||
startX: number;
|
||||
startY: number;
|
||||
last: PointerEventState | null;
|
||||
};
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
export class PointerEventState extends UIEventState {
|
||||
button: number;
|
||||
|
||||
containerOffset: Point;
|
||||
|
||||
delta: Point;
|
||||
|
||||
keys: {
|
||||
shift: boolean;
|
||||
cmd: boolean;
|
||||
alt: boolean;
|
||||
};
|
||||
|
||||
point: Point;
|
||||
|
||||
pressure: number;
|
||||
|
||||
raw: PointerEvent;
|
||||
|
||||
start: Point;
|
||||
|
||||
override type = 'pointerState';
|
||||
|
||||
get x() {
|
||||
return this.point.x;
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.point.y;
|
||||
}
|
||||
|
||||
constructor({ event, rect, startX, startY, last }: PointerEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const offsetY = event.clientY - rect.top;
|
||||
|
||||
this.raw = event;
|
||||
this.point = { x: offsetX, y: offsetY };
|
||||
this.containerOffset = { x: rect.left, y: rect.top };
|
||||
this.start = { x: startX, y: startY };
|
||||
this.delta = last
|
||||
? { x: offsetX - last.point.x, y: offsetY - last.point.y }
|
||||
: { x: 0, y: 0 };
|
||||
this.keys = {
|
||||
shift: event.shiftKey,
|
||||
cmd: event.metaKey || event.ctrlKey,
|
||||
alt: event.altKey,
|
||||
};
|
||||
this.button = last?.button || event.button;
|
||||
this.pressure = event.pressure;
|
||||
}
|
||||
}
|
||||
|
||||
export class MultiPointerEventState extends UIEventState {
|
||||
pointers: PointerEventState[];
|
||||
|
||||
override type = 'multiPointerState';
|
||||
|
||||
constructor(event: PointerEvent, pointers: PointerEventState[]) {
|
||||
super(event);
|
||||
this.pointers = pointers;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
pointerState: PointerEventState;
|
||||
multiPointerState: MultiPointerEventState;
|
||||
}
|
||||
}
|
||||
31
blocksuite/framework/std/src/event/state/source.ts
Normal file
31
blocksuite/framework/std/src/event/state/source.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
export enum EventScopeSourceType {
|
||||
// The event scope should be built by selection path
|
||||
Selection = 'selection',
|
||||
|
||||
// The event scope should be built by event target
|
||||
Target = 'target',
|
||||
}
|
||||
|
||||
export type EventSourceStateOptions = {
|
||||
event: Event;
|
||||
sourceType: EventScopeSourceType;
|
||||
};
|
||||
|
||||
export class EventSourceState extends UIEventState {
|
||||
readonly sourceType: EventScopeSourceType;
|
||||
|
||||
override type = 'sourceState';
|
||||
|
||||
constructor({ event, sourceType }: EventSourceStateOptions) {
|
||||
super(event);
|
||||
this.sourceType = sourceType;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
sourceState: EventSourceState;
|
||||
}
|
||||
}
|
||||
17
blocksuite/framework/std/src/event/utils.ts
Normal file
17
blocksuite/framework/std/src/event/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IPoint } from '@blocksuite/global/gfx';
|
||||
|
||||
export function isFarEnough(a: IPoint, b: IPoint) {
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
return Math.pow(dx, 2) + Math.pow(dy, 2) > 4;
|
||||
}
|
||||
|
||||
export function center(a: IPoint, b: IPoint) {
|
||||
return {
|
||||
x: (a.x + b.x) / 2,
|
||||
y: (a.y + b.y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export const toLowerCase = <T extends string>(str: T): Lowercase<T> =>
|
||||
str.toLowerCase() as Lowercase<T>;
|
||||
33
blocksuite/framework/std/src/extension/block-view.ts
Normal file
33
blocksuite/framework/std/src/extension/block-view.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { BlockViewIdentifier } from '../identifier.js';
|
||||
import type { BlockViewType } from '../spec/type.js';
|
||||
|
||||
/**
|
||||
* Create a block view extension.
|
||||
*
|
||||
* @param flavour The flavour of the block that the view is for.
|
||||
* @param view Lit literal template for the view. Example: `my-list-block`
|
||||
*
|
||||
* The view is a lit template that is used to render the block.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { BlockViewExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyListBlockViewExtension = BlockViewExtension(
|
||||
* 'affine:list',
|
||||
* literal`my-list-block`
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function BlockViewExtension(
|
||||
flavour: string,
|
||||
view: BlockViewType
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(BlockViewIdentifier(flavour), () => view);
|
||||
},
|
||||
};
|
||||
}
|
||||
44
blocksuite/framework/std/src/extension/config.ts
Normal file
44
blocksuite/framework/std/src/extension/config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { ConfigIdentifier } from '../identifier.js';
|
||||
|
||||
export interface ConfigFactory<Config extends Record<string, any>> {
|
||||
(config: Config): ExtensionType;
|
||||
identifier: ServiceIdentifier<Config>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a config extension.
|
||||
* A config extension provides a configuration object for a block flavour.
|
||||
* The configuration object can be used like:
|
||||
* ```ts
|
||||
* const config = std.provider.getOptional(ConfigIdentifier('my-flavour'));
|
||||
* ```
|
||||
*
|
||||
* @param configId The id of the config. Should be unique for each config.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||
* const MyConfigExtensionFactory = ConfigExtensionFactory<ConfigType>('my-flavour');
|
||||
* const MyConfigExtension = MyConfigExtensionFactory({
|
||||
* option1: 'value1',
|
||||
* option2: 'value2',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function ConfigExtensionFactory<Config extends Record<string, any>>(
|
||||
configId: string
|
||||
): ConfigFactory<Config> {
|
||||
const identifier = ConfigIdentifier(configId) as ServiceIdentifier<Config>;
|
||||
const extensionFactory = (config: Config): ExtensionType => ({
|
||||
setup: di => {
|
||||
di.override(ConfigIdentifier(configId), () => {
|
||||
return config;
|
||||
});
|
||||
},
|
||||
});
|
||||
extensionFactory.identifier = identifier;
|
||||
return extensionFactory;
|
||||
}
|
||||
373
blocksuite/framework/std/src/extension/dnd/index.ts
Normal file
373
blocksuite/framework/std/src/extension/dnd/index.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
type ElementGetFeedbackArgs,
|
||||
monitorForElements,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer';
|
||||
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
|
||||
import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview';
|
||||
import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
|
||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
|
||||
import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||
import {
|
||||
attachClosestEdge,
|
||||
type Edge,
|
||||
extractClosestEdge,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import { LifeCycleWatcherIdentifier } from '../../identifier.js';
|
||||
import { LifeCycleWatcher } from '../lifecycle-watcher.js';
|
||||
import type {
|
||||
ElementDragEventBaseArgs,
|
||||
ElementDragEventMap,
|
||||
ElementDropEventMap,
|
||||
ElementDropTargetFeedbackArgs,
|
||||
ElementMonitorFeedbackArgs,
|
||||
OriginalAutoScrollOption,
|
||||
OriginalDraggableOption,
|
||||
OriginalDropTargetOption,
|
||||
OriginalMonitorOption,
|
||||
} from './types.js';
|
||||
|
||||
export type DragEntity = { type: string };
|
||||
|
||||
export type DragFrom = { at: string };
|
||||
|
||||
export type DragFromBlockSuite = {
|
||||
at: 'blocksuite-editor';
|
||||
docId: string;
|
||||
};
|
||||
|
||||
export type DragPayload<
|
||||
E extends DragEntity = DragEntity,
|
||||
F extends DragFrom = DragFromBlockSuite,
|
||||
> = {
|
||||
bsEntity?: E;
|
||||
from?: F;
|
||||
};
|
||||
|
||||
export type DropPayload<T extends {} = {}> = {
|
||||
edge?: Edge;
|
||||
} & T;
|
||||
|
||||
export type DropEdge = Edge;
|
||||
|
||||
export interface DNDEntity {
|
||||
basic: DragEntity;
|
||||
}
|
||||
|
||||
export type DraggableOption<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
DropPayload extends {},
|
||||
> = Pick<OriginalDraggableOption, 'element' | 'dragHandle' | 'canDrag'> & {
|
||||
/**
|
||||
* Set drag data for the draggable element.
|
||||
* @see {@link ElementGetFeedbackArgs} for callback arguments
|
||||
* @param callback - callback to set drag
|
||||
*/
|
||||
setDragData?: (args: ElementGetFeedbackArgs) => PayloadEntity;
|
||||
|
||||
/**
|
||||
* Set external drag data for the draggable element.
|
||||
* @param callback - callback to set external drag data
|
||||
* @see {@link ElementGetFeedbackArgs} for callback arguments
|
||||
*/
|
||||
setExternalDragData?: (
|
||||
args: ElementGetFeedbackArgs
|
||||
) => ReturnType<
|
||||
Required<OriginalDraggableOption>['getInitialDataForExternal']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Set custom drag preview for the draggable element.
|
||||
*
|
||||
* `setDragPreview` is a function that will be called with a `container` element and other drag data as parameter when the drag preview is generating.
|
||||
* Append the custom element to the `container` which will be used to generate the preview. Once the drag preview is generated, the
|
||||
* `container` element and its children will be removed automatically.
|
||||
*
|
||||
* If you want to completely disable the drag preview, just set `setDragPreview` to `false`.
|
||||
*
|
||||
* @example
|
||||
* dnd.draggable({
|
||||
* // ...
|
||||
* setDragPreview: ({ container }) => {
|
||||
* const preview = document.createElement('div');
|
||||
* preview.style.width = '100px';
|
||||
* preview.style.height = '100px';
|
||||
* preview.style.backgroundColor = 'red';
|
||||
* preview.innerText = 'Custom Drag Preview';
|
||||
* container.appendChild(preview);
|
||||
*
|
||||
* return () => {
|
||||
* // do some cleanup
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* @param callback - callback to set custom drag preview
|
||||
* @returns
|
||||
*/
|
||||
setDragPreview?:
|
||||
| false
|
||||
| ((
|
||||
options: ElementDragEventBaseArgs<
|
||||
DragPayload<PayloadEntity, PayloadFrom>
|
||||
> & {
|
||||
/**
|
||||
* Allows you to use the native `setDragImage` function if you want
|
||||
* Although, we recommend using alternative techniques (see element adapter docs)
|
||||
*/
|
||||
nativeSetDragImage: DataTransfer['setDragImage'] | null;
|
||||
container: HTMLElement;
|
||||
setOffset: (
|
||||
offset: 'preserve' | 'center' | { x: number; y: number }
|
||||
) => void;
|
||||
}
|
||||
) => void | (() => void));
|
||||
} & ElementDragEventMap<DragPayload<PayloadEntity, PayloadFrom>, DropPayload>;
|
||||
|
||||
export type DropTargetOption<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
DropPayload extends {},
|
||||
> = {
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.element}
|
||||
*/
|
||||
element: HTMLElement;
|
||||
|
||||
/**
|
||||
* Allow drop position for the drop target.
|
||||
*/
|
||||
allowDropPosition?: Edge[];
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.getDropEffect}
|
||||
*/
|
||||
getDropEffect?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => DropTargetRecord['dropEffect'];
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.canDrop}
|
||||
*/
|
||||
canDrop?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.getData}
|
||||
*/
|
||||
setDropData?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => DropPayload;
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.getIsSticky}
|
||||
*/
|
||||
getIsSticky?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => boolean;
|
||||
} & ElementDropEventMap<DragPayload<PayloadEntity, PayloadFrom>, DropPayload>;
|
||||
|
||||
export type MonitorOption<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
DropPayload extends {},
|
||||
> = {
|
||||
/**
|
||||
* {@link OriginalMonitorOption.canMonitor}
|
||||
*/
|
||||
canMonitor?: (
|
||||
args: ElementMonitorFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => boolean;
|
||||
} & ElementDragEventMap<DragPayload<PayloadEntity, PayloadFrom>, DropPayload>;
|
||||
|
||||
export type AutoScroll<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
> = {
|
||||
element: HTMLElement;
|
||||
canScroll?: (
|
||||
args: ElementDragEventBaseArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => void;
|
||||
getAllowedAxis?: (
|
||||
args: ElementDragEventBaseArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => ReturnType<Required<OriginalAutoScrollOption>['getAllowedAxis']>;
|
||||
getConfiguration?: (
|
||||
args: ElementDragEventBaseArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => ReturnType<Required<OriginalAutoScrollOption>['getConfiguration']>;
|
||||
};
|
||||
|
||||
export const DndExtensionIdentifier = LifeCycleWatcherIdentifier(
|
||||
'DndController'
|
||||
) as ServiceIdentifier<DndController>;
|
||||
|
||||
export class DndController extends LifeCycleWatcher {
|
||||
static override key = 'DndController';
|
||||
|
||||
/**
|
||||
* Make an element draggable.
|
||||
*/
|
||||
draggable<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
DropData extends {} = {},
|
||||
>(
|
||||
args: DraggableOption<
|
||||
PayloadEntity,
|
||||
DragFromBlockSuite,
|
||||
DropPayload<DropData>
|
||||
>
|
||||
) {
|
||||
const {
|
||||
setDragData,
|
||||
setExternalDragData,
|
||||
setDragPreview,
|
||||
element,
|
||||
dragHandle,
|
||||
...rest
|
||||
} = args;
|
||||
|
||||
return draggable({
|
||||
...(rest as Partial<OriginalDraggableOption>),
|
||||
element,
|
||||
dragHandle,
|
||||
onGenerateDragPreview: options => {
|
||||
if (setDragPreview) {
|
||||
let state: typeof centerUnderPointer | { x: number; y: number };
|
||||
|
||||
const setOffset = (
|
||||
offset: 'preserve' | 'center' | { x: number; y: number }
|
||||
) => {
|
||||
if (offset === 'center') {
|
||||
state = centerUnderPointer;
|
||||
} else if (offset === 'preserve') {
|
||||
state = preserveOffsetOnSource({
|
||||
element: options.source.element,
|
||||
input: options.location.current.input,
|
||||
});
|
||||
} else if (typeof offset === 'object') {
|
||||
if (
|
||||
offset.x < 0 ||
|
||||
offset.y < 0 ||
|
||||
typeof offset.x === 'string' ||
|
||||
typeof offset.y === 'string'
|
||||
) {
|
||||
state = pointerOutsideOfPreview({
|
||||
x:
|
||||
typeof offset.x === 'number'
|
||||
? `${Math.abs(offset.x)}px`
|
||||
: offset.x,
|
||||
y:
|
||||
typeof offset.y === 'number'
|
||||
? `${Math.abs(offset.y)}px`
|
||||
: offset.y,
|
||||
});
|
||||
}
|
||||
state = offset;
|
||||
}
|
||||
};
|
||||
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: (...args) => {
|
||||
if (!state) {
|
||||
setOffset('center');
|
||||
}
|
||||
|
||||
if (typeof state === 'function') {
|
||||
return state(...args);
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
render: renderOption => {
|
||||
setDragPreview({
|
||||
setOffset,
|
||||
...options,
|
||||
...renderOption,
|
||||
});
|
||||
},
|
||||
nativeSetDragImage: options.nativeSetDragImage,
|
||||
});
|
||||
} else if (setDragPreview === false) {
|
||||
disableNativeDragPreview({
|
||||
nativeSetDragImage: options.nativeSetDragImage,
|
||||
});
|
||||
}
|
||||
},
|
||||
getInitialData: options => {
|
||||
const bsEntity = setDragData?.(options) ?? {};
|
||||
|
||||
return {
|
||||
bsEntity,
|
||||
from: {
|
||||
at: 'blocksuite-editor',
|
||||
docId: this.std.store.doc.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
getInitialDataForExternal: setExternalDragData
|
||||
? options => {
|
||||
return setExternalDragData?.(options);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an element a drop target.
|
||||
*/
|
||||
dropTarget<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
DropData extends {} = {},
|
||||
PayloadFrom extends DragFrom = DragFromBlockSuite,
|
||||
>(args: DropTargetOption<PayloadEntity, PayloadFrom, DropPayload<DropData>>) {
|
||||
const {
|
||||
element,
|
||||
setDropData,
|
||||
allowDropPosition = ['bottom', 'left', 'top', 'right'],
|
||||
...rest
|
||||
} = args;
|
||||
|
||||
return dropTargetForElements({
|
||||
element,
|
||||
getData: options => {
|
||||
const data = setDropData?.(options) ?? {};
|
||||
const edge = extractClosestEdge(
|
||||
attachClosestEdge(data, {
|
||||
element: options.element,
|
||||
input: options.input,
|
||||
allowedEdges: allowDropPosition,
|
||||
})
|
||||
);
|
||||
|
||||
return edge
|
||||
? {
|
||||
...data,
|
||||
edge,
|
||||
}
|
||||
: data;
|
||||
},
|
||||
...(rest as Partial<OriginalDropTargetOption>),
|
||||
});
|
||||
}
|
||||
|
||||
monitor<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
DropData extends {} = {},
|
||||
PayloadFrom extends DragFrom = DragFromBlockSuite,
|
||||
>(args: MonitorOption<PayloadEntity, PayloadFrom, DropPayload<DropData>>) {
|
||||
return monitorForElements(args as OriginalMonitorOption);
|
||||
}
|
||||
|
||||
autoScroll<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
PayloadFrom extends DragFrom = DragFromBlockSuite,
|
||||
>(options: AutoScroll<PayloadEntity, PayloadFrom>) {
|
||||
return autoScrollForElements(options as OriginalAutoScrollOption);
|
||||
}
|
||||
}
|
||||
118
blocksuite/framework/std/src/extension/dnd/types.ts
Normal file
118
blocksuite/framework/std/src/extension/dnd/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
ElementDropTargetGetFeedbackArgs,
|
||||
ElementMonitorGetFeedbackArgs,
|
||||
monitorForElements,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import type {
|
||||
DragLocation,
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
DragLocationHistory,
|
||||
DropTargetRecord,
|
||||
ElementDragType,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||
import type { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||
|
||||
export type ElementDragEventBaseArgs<Payload, DropPayload = {}> = {
|
||||
/**
|
||||
* {@link DragLocationHistory} of the drag
|
||||
*/
|
||||
location: {
|
||||
/**
|
||||
* {@link DragLocationHistory.initial}
|
||||
*/
|
||||
initial: DragLocationWithPayload<DropPayload>;
|
||||
/**
|
||||
* {@link DragLocationHistory.current}
|
||||
*/
|
||||
current: DragLocationWithPayload<DropPayload>;
|
||||
/**
|
||||
* {@link DragLocationHistory.previous}
|
||||
*/
|
||||
previous: Pick<DragLocationWithPayload<DropPayload>, 'dropTargets'>;
|
||||
};
|
||||
source: Omit<ElementDragType['payload'], 'data'> & { data: Payload };
|
||||
};
|
||||
|
||||
export type DragLocationWithPayload<Payload> = Omit<
|
||||
DragLocation,
|
||||
'dropTargets'
|
||||
> & {
|
||||
dropTargets: DropTargetRecordWithPayload<Payload>[];
|
||||
};
|
||||
|
||||
export type DropTargetRecordWithPayload<Payload> = Omit<
|
||||
DropTargetRecord,
|
||||
'data'
|
||||
> & {
|
||||
data: Payload;
|
||||
};
|
||||
|
||||
export type ElementDragEventMap<DragPayload, DropPayload> = {
|
||||
onDragStart?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload>
|
||||
) => void;
|
||||
onDrag?: (data: ElementDragEventBaseArgs<DragPayload, DropPayload>) => void;
|
||||
onDrop?: (data: ElementDragEventBaseArgs<DragPayload, DropPayload>) => void;
|
||||
onDropTargetChange?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload>
|
||||
) => void;
|
||||
};
|
||||
|
||||
type DropTargetLocalizedData = {
|
||||
self: DropTargetRecord;
|
||||
};
|
||||
|
||||
export type ElementDropTargetFeedbackArgs<Payload> = Omit<
|
||||
ElementDropTargetGetFeedbackArgs,
|
||||
'source'
|
||||
> & {
|
||||
source: Omit<ElementDragType['payload'], 'data'> & { data: Payload };
|
||||
};
|
||||
|
||||
export type ElementDropEventMap<DragPayload, DropPayload> = {
|
||||
onDragStart?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDrag?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDrop?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDropTargetChange?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDragEnter?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDragLeave?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type ElementMonitorFeedbackArgs<Payload> = Omit<
|
||||
ElementMonitorGetFeedbackArgs,
|
||||
'source'
|
||||
> & {
|
||||
source: Omit<ElementDragType['payload'], 'data'> & { data: Payload };
|
||||
};
|
||||
|
||||
export type OriginalDraggableOption = Parameters<typeof draggable>[0];
|
||||
|
||||
export type OriginalDropTargetOption = Parameters<
|
||||
typeof dropTargetForElements
|
||||
>[0];
|
||||
|
||||
export type OriginalMonitorOption = Parameters<typeof monitorForElements>[0];
|
||||
|
||||
export type OriginalAutoScrollOption = Parameters<
|
||||
typeof autoScrollForElements
|
||||
>[0];
|
||||
49
blocksuite/framework/std/src/extension/editor-life-cycle.ts
Normal file
49
blocksuite/framework/std/src/extension/editor-life-cycle.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { BlockStdScope } from '../scope/std-scope';
|
||||
import { LifeCycleWatcher } from './lifecycle-watcher';
|
||||
|
||||
export class EditorLifeCycleExtension extends LifeCycleWatcher {
|
||||
static override key = 'editor-life-cycle';
|
||||
|
||||
disposables = new DisposableGroup();
|
||||
|
||||
readonly slots = {
|
||||
created: new Subject<void>(),
|
||||
mounted: new Subject<void>(),
|
||||
rendered: new Subject<void>(),
|
||||
unmounted: new Subject<void>(),
|
||||
};
|
||||
|
||||
constructor(override readonly std: BlockStdScope) {
|
||||
super(std);
|
||||
|
||||
this.disposables.add(this.slots.created);
|
||||
this.disposables.add(this.slots.mounted);
|
||||
this.disposables.add(this.slots.rendered);
|
||||
this.disposables.add(this.slots.unmounted);
|
||||
}
|
||||
|
||||
override created() {
|
||||
super.created();
|
||||
this.slots.created.next();
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
super.mounted();
|
||||
this.slots.mounted.next();
|
||||
}
|
||||
|
||||
override rendered() {
|
||||
super.rendered();
|
||||
this.slots.rendered.next();
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
super.unmounted();
|
||||
this.slots.unmounted.next();
|
||||
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
26
blocksuite/framework/std/src/extension/flavour.ts
Normal file
26
blocksuite/framework/std/src/extension/flavour.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { BlockFlavourIdentifier } from '../identifier.js';
|
||||
|
||||
/**
|
||||
* Create a flavour extension.
|
||||
*
|
||||
* @param flavour
|
||||
* The flavour of the block that the extension is for.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { FlavourExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyFlavourExtension = FlavourExtension('my-flavour');
|
||||
* ```
|
||||
*/
|
||||
export function FlavourExtension(flavour: string): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(BlockFlavourIdentifier(flavour), () => ({
|
||||
flavour,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
10
blocksuite/framework/std/src/extension/index.ts
Normal file
10
blocksuite/framework/std/src/extension/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './block-view.js';
|
||||
export * from './config.js';
|
||||
export * from './dnd/index.js';
|
||||
export * from './editor-life-cycle.js';
|
||||
export * from './flavour.js';
|
||||
export * from './keymap.js';
|
||||
export * from './lifecycle-watcher.js';
|
||||
export * from './service.js';
|
||||
export * from './service-manager.js';
|
||||
export * from './widget-view-map.js';
|
||||
49
blocksuite/framework/std/src/extension/keymap.ts
Normal file
49
blocksuite/framework/std/src/extension/keymap.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { EventOptions, UIEventHandler } from '../event/index.js';
|
||||
import { KeymapIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
|
||||
let id = 1;
|
||||
|
||||
/**
|
||||
* Create a keymap extension.
|
||||
*
|
||||
* @param keymapFactory
|
||||
* Create keymap of the extension.
|
||||
* It should return an object with `keymap` and `options`.
|
||||
*
|
||||
* `keymap` is a record of keymap.
|
||||
*
|
||||
* @param options
|
||||
* `options` is an optional object that restricts the event to be handled.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { KeymapExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyKeymapExtension = KeymapExtension(std => {
|
||||
* return {
|
||||
* keymap: {
|
||||
* 'mod-a': SelectAll
|
||||
* }
|
||||
* options: {
|
||||
* flavour: 'affine:paragraph'
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function KeymapExtension(
|
||||
keymapFactory: (std: BlockStdScope) => Record<string, UIEventHandler>,
|
||||
options?: EventOptions
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(KeymapIdentifier(`Keymap-${id++}`), {
|
||||
getter: keymapFactory,
|
||||
options,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
70
blocksuite/framework/std/src/extension/lifecycle-watcher.ts
Normal file
70
blocksuite/framework/std/src/extension/lifecycle-watcher.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import { LifeCycleWatcherIdentifier, StdIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
|
||||
/**
|
||||
* A life cycle watcher is an extension that watches the life cycle of the editor.
|
||||
* It is used to perform actions when the editor is created, mounted, rendered, or unmounted.
|
||||
*
|
||||
* When creating a life cycle watcher, you must define a key that is unique to the watcher.
|
||||
* The key is used to identify the watcher in the dependency injection container.
|
||||
* ```ts
|
||||
* class MyLifeCycleWatcher extends LifeCycleWatcher {
|
||||
* static override readonly key = 'my-life-cycle-watcher';
|
||||
* ```
|
||||
*
|
||||
* In the life cycle watcher, the methods will be called in the following order:
|
||||
* 1. `created`: Called when the std is created.
|
||||
* 2. `rendered`: Called when `std.render` is called.
|
||||
* 3. `mounted`: Called when the editor host is mounted.
|
||||
* 4. `unmounted`: Called when the editor host is unmounted.
|
||||
*/
|
||||
export abstract class LifeCycleWatcher extends Extension {
|
||||
static key: string;
|
||||
|
||||
constructor(readonly std: BlockStdScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Key is not defined in the LifeCycleWatcher'
|
||||
);
|
||||
}
|
||||
|
||||
di.add(this as unknown as { new (std: BlockStdScope): LifeCycleWatcher }, [
|
||||
StdIdentifier,
|
||||
]);
|
||||
|
||||
di.addImpl(LifeCycleWatcherIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when std is created.
|
||||
*/
|
||||
created() {}
|
||||
|
||||
/**
|
||||
* Called when editor host is mounted.
|
||||
* Which means the editor host emit the `connectedCallback` lifecycle event.
|
||||
*/
|
||||
mounted() {}
|
||||
|
||||
/**
|
||||
* Called when `std.render` is called.
|
||||
*/
|
||||
rendered() {}
|
||||
|
||||
/**
|
||||
* Called when editor host is unmounted.
|
||||
* Which means the editor host emit the `disconnectedCallback` lifecycle event.
|
||||
*/
|
||||
unmounted() {}
|
||||
}
|
||||
22
blocksuite/framework/std/src/extension/service-manager.ts
Normal file
22
blocksuite/framework/std/src/extension/service-manager.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { BlockServiceIdentifier } from '../identifier.js';
|
||||
import { LifeCycleWatcher } from './lifecycle-watcher.js';
|
||||
|
||||
export class ServiceManager extends LifeCycleWatcher {
|
||||
static override readonly key = 'serviceManager';
|
||||
|
||||
override mounted() {
|
||||
super.mounted();
|
||||
|
||||
this.std.provider.getAll(BlockServiceIdentifier).forEach(service => {
|
||||
service.mounted();
|
||||
});
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
super.unmounted();
|
||||
|
||||
this.std.provider.getAll(BlockServiceIdentifier).forEach(service => {
|
||||
service.unmounted();
|
||||
});
|
||||
}
|
||||
}
|
||||
113
blocksuite/framework/std/src/extension/service.ts
Normal file
113
blocksuite/framework/std/src/extension/service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { EventName, UIEventHandler } from '../event/index.js';
|
||||
import {
|
||||
BlockFlavourIdentifier,
|
||||
BlockServiceIdentifier,
|
||||
StdIdentifier,
|
||||
} from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* BlockService is deprecated. You should reconsider where to put your feature.
|
||||
*
|
||||
* BlockService is a legacy extension that is used to provide services to the block.
|
||||
* In the previous version of BlockSuite, block service provides a way to extend the block.
|
||||
* However, in the new version, we recommend using the new extension system.
|
||||
*/
|
||||
export abstract class BlockService extends Extension {
|
||||
static flavour: string;
|
||||
|
||||
readonly disposables = new DisposableGroup();
|
||||
|
||||
readonly flavour: string;
|
||||
|
||||
get collection() {
|
||||
return this.std.workspace;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
get selectionManager() {
|
||||
return this.std.selection;
|
||||
}
|
||||
|
||||
get uiEventDispatcher() {
|
||||
return this.std.event;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly std: BlockStdScope,
|
||||
readonly flavourProvider: { flavour: string }
|
||||
) {
|
||||
super();
|
||||
this.flavour = flavourProvider.flavour;
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.flavour) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Flavour is not defined in the BlockService'
|
||||
);
|
||||
}
|
||||
di.add(
|
||||
this as unknown as {
|
||||
new (
|
||||
std: BlockStdScope,
|
||||
flavourProvider: { flavour: string }
|
||||
): BlockService;
|
||||
},
|
||||
[StdIdentifier, BlockFlavourIdentifier(this.flavour)]
|
||||
);
|
||||
di.addImpl(BlockServiceIdentifier(this.flavour), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
|
||||
bindHotKey(
|
||||
keymap: Record<string, UIEventHandler>,
|
||||
options?: { global: boolean }
|
||||
) {
|
||||
this.disposables.add(
|
||||
this.uiEventDispatcher.bindHotkey(keymap, {
|
||||
flavour: options?.global ? undefined : this.flavour,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// life cycle start
|
||||
dispose() {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
// event handlers start
|
||||
handleEvent(
|
||||
name: EventName,
|
||||
fn: UIEventHandler,
|
||||
options?: { global: boolean }
|
||||
) {
|
||||
this.disposables.add(
|
||||
this.uiEventDispatcher.add(name, fn, {
|
||||
flavour: options?.global ? undefined : this.flavour,
|
||||
})
|
||||
);
|
||||
}
|
||||
// life cycle end
|
||||
|
||||
mounted() {}
|
||||
|
||||
unmounted() {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
40
blocksuite/framework/std/src/extension/widget-view-map.ts
Normal file
40
blocksuite/framework/std/src/extension/widget-view-map.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { WidgetViewIdentifier } from '../identifier.js';
|
||||
import type { WidgetViewType } from '../spec/type.js';
|
||||
|
||||
/**
|
||||
* Create a widget view extension.
|
||||
*
|
||||
* @param flavour The flavour of the block that the widget view is for.
|
||||
* @param id The id of the widget view.
|
||||
* @param view The widget view lit literal.
|
||||
*
|
||||
* A widget view is to provide a widget view for a block.
|
||||
* For every target block, it's view will be rendered with the widget view.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { WidgetViewExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyWidgetViewExtension = WidgetViewExtension('my-flavour', 'my-widget', literal`my-widget-view`);
|
||||
*/
|
||||
export function WidgetViewExtension(
|
||||
flavour: string,
|
||||
id: string,
|
||||
view: WidgetViewType
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
if (flavour.includes('|') || id.includes('|')) {
|
||||
console.error(`Register view failed:`);
|
||||
console.error(
|
||||
`flavour or id cannot include '|', flavour: ${flavour}, id: ${id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = `${flavour}|${id}`;
|
||||
di.addImpl(WidgetViewIdentifier(key), view);
|
||||
},
|
||||
};
|
||||
}
|
||||
284
blocksuite/framework/std/src/gfx/controller.ts
Normal file
284
blocksuite/framework/std/src/gfx/controller.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
Bound,
|
||||
getCommonBound,
|
||||
getCommonBoundWithRotation,
|
||||
type IBound,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
import last from 'lodash-es/last';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js';
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
import { onSurfaceAdded } from '../utils/gfx.js';
|
||||
import type { BlockComponent } from '../view/index.js';
|
||||
import type { CursorType } from './cursor.js';
|
||||
import {
|
||||
GfxClassExtenderIdentifier,
|
||||
GfxExtensionIdentifier,
|
||||
} from './extension.js';
|
||||
import { GridManager } from './grid.js';
|
||||
import { gfxControllerKey } from './identifiers.js';
|
||||
import { KeyboardController } from './keyboard.js';
|
||||
import { LayerManager } from './layer.js';
|
||||
import type { PointTestOptions } from './model/base.js';
|
||||
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
|
||||
import type { SurfaceBlockModel } from './model/surface/surface-model.js';
|
||||
import { FIT_TO_SCREEN_PADDING, Viewport, ZOOM_INITIAL } from './viewport.js';
|
||||
|
||||
export class GfxController extends LifeCycleWatcher {
|
||||
static override key = gfxControllerKey;
|
||||
|
||||
private readonly _disposables: DisposableGroup = new DisposableGroup();
|
||||
|
||||
private readonly _surface$ = new Signal<SurfaceBlockModel | null>(null);
|
||||
|
||||
readonly cursor$ = new Signal<CursorType>();
|
||||
|
||||
readonly keyboard: KeyboardController;
|
||||
|
||||
readonly viewport: Viewport = new Viewport();
|
||||
|
||||
get grid() {
|
||||
return this.std.get(GridManager);
|
||||
}
|
||||
|
||||
get layer() {
|
||||
return this.std.get(LayerManager);
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get elementsBound() {
|
||||
return getCommonBoundWithRotation(this.gfxElements);
|
||||
}
|
||||
|
||||
get gfxElements(): GfxModel[] {
|
||||
return [...this.layer.blocks, ...this.layer.canvasElements];
|
||||
}
|
||||
|
||||
get surface$() {
|
||||
return this._surface$;
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this._surface$.peek();
|
||||
}
|
||||
|
||||
get surfaceComponent(): BlockComponent | null {
|
||||
return this.surface
|
||||
? (this.std.view.getBlock(this.surface.id) ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
super(std);
|
||||
|
||||
this.keyboard = new KeyboardController(std);
|
||||
|
||||
this._disposables.add(
|
||||
onSurfaceAdded(this.doc, surface => {
|
||||
this._surface$.value = surface;
|
||||
})
|
||||
);
|
||||
this._disposables.add(this.viewport);
|
||||
this._disposables.add(this.keyboard);
|
||||
|
||||
this.std.provider.getAll(GfxClassExtenderIdentifier).forEach(ext => {
|
||||
ext.extendFn(this);
|
||||
});
|
||||
}
|
||||
|
||||
deleteElement(element: GfxModel | BlockModel<object> | string): void {
|
||||
element = typeof element === 'string' ? element : element.id;
|
||||
|
||||
assertType<string>(element);
|
||||
|
||||
if (this.surface?.hasElementById(element)) {
|
||||
this.surface.deleteElement(element);
|
||||
} else {
|
||||
const block = this.doc.getBlock(element)?.model;
|
||||
block && this.doc.deleteBlock(block);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a block or element by its id.
|
||||
* Note that non-gfx block can also be queried in this method.
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
getElementById<
|
||||
T extends GfxModel | BlockModel<object> = GfxModel | BlockModel<object>,
|
||||
>(id: string): T | null {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return (
|
||||
this.surface?.getElementById(id) ?? this.doc.getBlock(id)?.model ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get elements on a specific point.
|
||||
* @param x
|
||||
* @param y
|
||||
* @param options
|
||||
*/
|
||||
getElementByPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options: { all: true } & PointTestOptions
|
||||
): GfxModel[];
|
||||
getElementByPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options?: { all?: false } & PointTestOptions
|
||||
): GfxModel | null;
|
||||
getElementByPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions & {
|
||||
all?: boolean;
|
||||
} = { all: false, hitThreshold: 10 }
|
||||
): GfxModel | GfxModel[] | null {
|
||||
options.zoom = this.viewport.zoom;
|
||||
options.hitThreshold ??= 10;
|
||||
|
||||
const hitThreshold = options.hitThreshold;
|
||||
const responsePadding = options.responsePadding ?? [
|
||||
hitThreshold / 2,
|
||||
hitThreshold / 2,
|
||||
];
|
||||
const all = options.all ?? false;
|
||||
const hitTestBound = {
|
||||
x: x - responsePadding[0],
|
||||
y: y - responsePadding[1],
|
||||
w: responsePadding[0] * 2,
|
||||
h: responsePadding[1] * 2,
|
||||
};
|
||||
|
||||
const candidates = this.grid.search(hitTestBound);
|
||||
const picked = candidates.filter(
|
||||
elm =>
|
||||
elm.includesPoint(x, y, options as PointTestOptions, this.std.host) ||
|
||||
elm.externalBound?.isPointInBound([x, y])
|
||||
);
|
||||
|
||||
picked.sort(this.layer.compare);
|
||||
|
||||
if (all) {
|
||||
return picked;
|
||||
}
|
||||
|
||||
return last(picked) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all elements in an area.
|
||||
* @param bound
|
||||
* @param options
|
||||
*/
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options?: { type: 'all' }
|
||||
): GfxModel[];
|
||||
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options: { type: 'canvas' }
|
||||
): GfxPrimitiveElementModel[];
|
||||
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options: { type: 'block' }
|
||||
): GfxBlockElementModel[];
|
||||
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options: { type: 'block' | 'canvas' | 'all' } = {
|
||||
type: 'all',
|
||||
}
|
||||
): GfxModel[] {
|
||||
bound = bound instanceof Bound ? bound : Bound.from(bound);
|
||||
|
||||
let candidates = this.grid.search(bound);
|
||||
|
||||
if (options.type !== 'all') {
|
||||
const filter =
|
||||
options.type === 'block'
|
||||
? (elm: GfxModel) => elm instanceof GfxBlockElementModel
|
||||
: (elm: GfxModel) => elm instanceof GfxPrimitiveElementModel;
|
||||
|
||||
candidates = candidates.filter(filter);
|
||||
}
|
||||
|
||||
candidates.sort(this.layer.compare);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
getElementsByType(type: string): (GfxModel | BlockModel<object>)[] {
|
||||
return (
|
||||
this.surface?.getElementsByType(type) ??
|
||||
this.doc.getBlocksByFlavour(type).map(b => b.model)
|
||||
);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
this.viewport.setShellElement(this.std.host);
|
||||
this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => {
|
||||
ext.mounted();
|
||||
});
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => {
|
||||
ext.unmounted();
|
||||
});
|
||||
this.viewport.clearViewportElement();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
updateElement(
|
||||
element: GfxModel | string,
|
||||
props: Record<string, unknown>
|
||||
): void {
|
||||
const elemId = typeof element === 'string' ? element : element.id;
|
||||
|
||||
if (this.surface?.hasElementById(elemId)) {
|
||||
this.surface.updateElement(elemId, props);
|
||||
} else {
|
||||
const block = this.doc.getBlock(elemId);
|
||||
block && this.doc.updateBlock(block.model, props);
|
||||
}
|
||||
}
|
||||
|
||||
fitToScreen(
|
||||
options: {
|
||||
bounds?: Bound[];
|
||||
smooth?: boolean;
|
||||
padding?: [number, number, number, number];
|
||||
} = {
|
||||
smooth: false,
|
||||
padding: [0, 0, 0, 0],
|
||||
}
|
||||
) {
|
||||
const elemBounds =
|
||||
options.bounds ??
|
||||
this.gfxElements.map(element => Bound.deserialize(element.xywh));
|
||||
const commonBound = getCommonBound(elemBounds);
|
||||
const { zoom, centerX, centerY } = this.viewport.getFitToScreenData(
|
||||
commonBound,
|
||||
options.padding,
|
||||
ZOOM_INITIAL,
|
||||
FIT_TO_SCREEN_PADDING
|
||||
);
|
||||
|
||||
this.viewport.setViewport(zoom, [centerX, centerY], options.smooth);
|
||||
}
|
||||
}
|
||||
54
blocksuite/framework/std/src/gfx/cursor.ts
Normal file
54
blocksuite/framework/std/src/gfx/cursor.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type StandardCursor =
|
||||
| 'default'
|
||||
| 'pointer'
|
||||
| 'move'
|
||||
| 'text'
|
||||
| 'crosshair'
|
||||
| 'not-allowed'
|
||||
| 'grab'
|
||||
| 'grabbing'
|
||||
| 'nwse-resize'
|
||||
| 'nesw-resize'
|
||||
| 'ew-resize'
|
||||
| 'ns-resize'
|
||||
| 'n-resize'
|
||||
| 's-resize'
|
||||
| 'w-resize'
|
||||
| 'e-resize'
|
||||
| 'ne-resize'
|
||||
| 'se-resize'
|
||||
| 'sw-resize'
|
||||
| 'nw-resize'
|
||||
| 'zoom-in'
|
||||
| 'zoom-out'
|
||||
| 'help'
|
||||
| 'wait'
|
||||
| 'progress'
|
||||
| 'copy'
|
||||
| 'alias'
|
||||
| 'context-menu'
|
||||
| 'cell'
|
||||
| 'vertical-text'
|
||||
| 'no-drop'
|
||||
| 'not-allowed'
|
||||
| 'all-scroll'
|
||||
| 'col-resize'
|
||||
| 'row-resize'
|
||||
| 'none'
|
||||
| 'inherit'
|
||||
| 'initial'
|
||||
| 'unset';
|
||||
|
||||
export type URLCursor = `url(${string})`;
|
||||
|
||||
export type URLCursorWithCoords = `url(${string}) ${number} ${number}`;
|
||||
|
||||
export type URLCursorWithFallback =
|
||||
| `${URLCursor}, ${StandardCursor}`
|
||||
| `${URLCursorWithCoords}, ${StandardCursor}`;
|
||||
|
||||
export type CursorType =
|
||||
| StandardCursor
|
||||
| URLCursor
|
||||
| URLCursorWithCoords
|
||||
| URLCursorWithFallback;
|
||||
53
blocksuite/framework/std/src/gfx/extension.ts
Normal file
53
blocksuite/framework/std/src/gfx/extension.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { GfxController } from './controller.js';
|
||||
import { GfxControllerIdentifier } from './identifiers.js';
|
||||
|
||||
export const GfxExtensionIdentifier =
|
||||
createIdentifier<GfxExtension>('GfxExtension');
|
||||
|
||||
export const GfxClassExtenderIdentifier = createIdentifier<{
|
||||
extendFn: (gfx: GfxController) => void;
|
||||
}>('GfxClassExtender');
|
||||
|
||||
export abstract class GfxExtension extends Extension {
|
||||
static key: string;
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
constructor(protected readonly gfx: GfxController) {
|
||||
super();
|
||||
}
|
||||
|
||||
// This method is used to extend the GfxController
|
||||
static extendGfx(_: GfxController) {}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'key is not defined in the GfxExtension'
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(GfxClassExtenderIdentifier(this.key), {
|
||||
extendFn: this.extendGfx,
|
||||
});
|
||||
|
||||
di.add(this as unknown as { new (gfx: GfxController): GfxExtension }, [
|
||||
GfxControllerIdentifier,
|
||||
]);
|
||||
|
||||
di.addImpl(GfxExtensionIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
|
||||
mounted() {}
|
||||
|
||||
unmounted() {}
|
||||
}
|
||||
513
blocksuite/framework/std/src/gfx/grid.ts
Normal file
513
blocksuite/framework/std/src/gfx/grid.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { IBound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
getBoundWithRotation,
|
||||
intersects,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { compare } from '../utils/layer.js';
|
||||
import { GfxExtension } from './extension.js';
|
||||
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
|
||||
import { GfxLocalElementModel } from './model/surface/local-element-model.js';
|
||||
import { SurfaceBlockModel } from './model/surface/surface-model.js';
|
||||
|
||||
function getGridIndex(val: number) {
|
||||
return Math.ceil(val / DEFAULT_GRID_SIZE) - 1;
|
||||
}
|
||||
|
||||
function rangeFromBound(a: IBound): number[] {
|
||||
if (a.rotate) a = getBoundWithRotation(a);
|
||||
const minRow = getGridIndex(a.x);
|
||||
const maxRow = getGridIndex(a.x + a.w);
|
||||
const minCol = getGridIndex(a.y);
|
||||
const maxCol = getGridIndex(a.y + a.h);
|
||||
return [minRow, maxRow, minCol, maxCol];
|
||||
}
|
||||
|
||||
function rangeFromElement(ele: GfxModel | GfxLocalElementModel): number[] {
|
||||
const bound = ele.elementBound;
|
||||
|
||||
bound.w += ele.responseExtension[0] * 2;
|
||||
bound.h += ele.responseExtension[1] * 2;
|
||||
bound.x -= ele.responseExtension[0];
|
||||
bound.y -= ele.responseExtension[1];
|
||||
|
||||
const minRow = getGridIndex(bound.x);
|
||||
const maxRow = getGridIndex(bound.maxX);
|
||||
const minCol = getGridIndex(bound.y);
|
||||
const maxCol = getGridIndex(bound.maxY);
|
||||
return [minRow, maxRow, minCol, maxCol];
|
||||
}
|
||||
|
||||
function rangeFromElementExternal(ele: GfxModel): number[] | null {
|
||||
if (!ele.externalXYWH) return null;
|
||||
|
||||
const bound = Bound.deserialize(ele.externalXYWH);
|
||||
const minRow = getGridIndex(bound.x);
|
||||
const maxRow = getGridIndex(bound.maxX);
|
||||
const minCol = getGridIndex(bound.y);
|
||||
const maxCol = getGridIndex(bound.maxY);
|
||||
return [minRow, maxRow, minCol, maxCol];
|
||||
}
|
||||
|
||||
export const DEFAULT_GRID_SIZE = 3000;
|
||||
|
||||
const typeFilters = {
|
||||
block: (model: GfxModel | GfxLocalElementModel) =>
|
||||
model instanceof GfxBlockElementModel,
|
||||
canvas: (model: GfxModel | GfxLocalElementModel) =>
|
||||
model instanceof GfxPrimitiveElementModel,
|
||||
local: (model: GfxModel | GfxLocalElementModel) =>
|
||||
model instanceof GfxLocalElementModel,
|
||||
};
|
||||
|
||||
export type BuiltInFilterModelMap = {
|
||||
block: GfxBlockElementModel;
|
||||
canvas: GfxPrimitiveElementModel;
|
||||
local: GfxLocalElementModel;
|
||||
};
|
||||
|
||||
export type BuiltInFilterType = keyof typeof typeFilters;
|
||||
|
||||
type FilterFunc = (model: GfxModel | GfxLocalElementModel) => boolean;
|
||||
|
||||
export class GridManager extends GfxExtension {
|
||||
static override key = 'grid';
|
||||
|
||||
private readonly _elementToGrids = new Map<
|
||||
GfxModel | GfxLocalElementModel,
|
||||
Set<Set<GfxModel | GfxLocalElementModel>>
|
||||
>();
|
||||
|
||||
private readonly _externalElementToGrids = new Map<
|
||||
GfxModel,
|
||||
Set<Set<GfxModel>>
|
||||
>();
|
||||
|
||||
private readonly _externalGrids = new Map<string, Set<GfxModel>>();
|
||||
|
||||
private readonly _grids = new Map<
|
||||
string,
|
||||
Set<GfxModel | GfxLocalElementModel>
|
||||
>();
|
||||
|
||||
get isEmpty() {
|
||||
return this._grids.size === 0;
|
||||
}
|
||||
|
||||
private _addToExternalGrids(element: GfxModel) {
|
||||
const range = rangeFromElementExternal(element);
|
||||
|
||||
if (!range) {
|
||||
this._removeFromExternalGrids(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const [minRow, maxRow, minCol, maxCol] = range;
|
||||
const grids = new Set<Set<GfxModel>>();
|
||||
this._externalElementToGrids.set(element, grids);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
let grid = this._getExternalGrid(i, j);
|
||||
if (!grid) {
|
||||
grid = this._createExternalGrid(i, j);
|
||||
}
|
||||
grid.add(element);
|
||||
grids.add(grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _createExternalGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
const elements = new Set<GfxModel>();
|
||||
this._externalGrids.set(id, elements);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private _createGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
const elements = new Set<GfxModel>();
|
||||
this._grids.set(id, elements);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private _getExternalGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
return this._externalGrids.get(id);
|
||||
}
|
||||
|
||||
private _getGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
return this._grids.get(id);
|
||||
}
|
||||
|
||||
private _removeFromExternalGrids(element: GfxModel) {
|
||||
const grids = this._externalElementToGrids.get(element);
|
||||
if (grids) {
|
||||
for (const grid of grids) {
|
||||
grid.delete(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _searchExternal(
|
||||
bound: IBound,
|
||||
options: { filterFunc: FilterFunc; strict: boolean }
|
||||
): Set<GfxModel> {
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
|
||||
const results = new Set<GfxModel>();
|
||||
const b = Bound.from(bound);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
const gridElements = this._getExternalGrid(i, j);
|
||||
if (!gridElements) continue;
|
||||
|
||||
for (const element of gridElements) {
|
||||
const externalBound = element.externalBound;
|
||||
if (
|
||||
options.filterFunc(element) &&
|
||||
externalBound &&
|
||||
(options.strict
|
||||
? b.contains(externalBound)
|
||||
: intersects(externalBound, bound))
|
||||
) {
|
||||
results.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private _toFilterFunc(filters: (keyof typeof typeFilters | FilterFunc)[]) {
|
||||
const filterFuncs: FilterFunc[] = filters.map(filter => {
|
||||
if (typeof filter === 'function') {
|
||||
return filter;
|
||||
}
|
||||
return typeFilters[filter];
|
||||
});
|
||||
|
||||
return (model: GfxModel | GfxLocalElementModel) =>
|
||||
filterFuncs.some(filter => filter(model));
|
||||
}
|
||||
|
||||
add(element: GfxModel | GfxLocalElementModel) {
|
||||
if (!(element instanceof GfxLocalElementModel)) {
|
||||
this._addToExternalGrids(element);
|
||||
}
|
||||
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromElement(element);
|
||||
const grids = new Set<Set<GfxModel | GfxLocalElementModel>>();
|
||||
this._elementToGrids.set(element, grids);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
let grid = this._getGrid(i, j);
|
||||
if (!grid) {
|
||||
grid = this._createGrid(i, j);
|
||||
}
|
||||
grid.add(element);
|
||||
grids.add(grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boundHasChanged(a: IBound, b: IBound) {
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(a);
|
||||
const [minRow2, maxRow2, minCol2, maxCol2] = rangeFromBound(b);
|
||||
return (
|
||||
minRow !== minRow2 ||
|
||||
maxRow !== maxRow2 ||
|
||||
minCol !== minCol2 ||
|
||||
maxCol !== maxCol2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param bound
|
||||
* @param strict
|
||||
* @param reverseChecking If true, check if the bound is inside the elements instead of checking if the elements are inside the bound
|
||||
* @returns
|
||||
*/
|
||||
has(
|
||||
bound: IBound,
|
||||
strict: boolean = false,
|
||||
reverseChecking: boolean = false,
|
||||
filter?: (model: GfxModel | GfxLocalElementModel) => boolean
|
||||
) {
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
|
||||
const b = Bound.from(bound);
|
||||
const check = reverseChecking
|
||||
? (target: Bound) => {
|
||||
return strict ? target.contains(b) : intersects(b, target);
|
||||
}
|
||||
: (target: Bound) => {
|
||||
return strict ? b.contains(target) : intersects(target, b);
|
||||
};
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
const gridElements = this._getGrid(i, j);
|
||||
if (!gridElements) continue;
|
||||
for (const element of gridElements) {
|
||||
if ((!filter || filter(element)) && check(element.elementBound)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
remove(element: GfxModel | GfxLocalElementModel) {
|
||||
const grids = this._elementToGrids.get(element);
|
||||
if (grids) {
|
||||
for (const grid of grids) {
|
||||
grid.delete(element);
|
||||
}
|
||||
}
|
||||
this._elementToGrids.delete(element);
|
||||
|
||||
if (!(element instanceof GfxLocalElementModel)) {
|
||||
this._removeFromExternalGrids(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for elements in a bound.
|
||||
* @param bound
|
||||
* @param options
|
||||
*/
|
||||
search<T extends BuiltInFilterType = 'canvas' | 'block'>(
|
||||
bound: IBound,
|
||||
options?: {
|
||||
/**
|
||||
* If true, only return elements that are completely inside the bound.
|
||||
* Default is false.
|
||||
*/
|
||||
strict?: boolean;
|
||||
/**
|
||||
* If true, return a set of elements instead of an array
|
||||
*/
|
||||
useSet?: false;
|
||||
/**
|
||||
* Use this to filter the elements, if not provided, it will return blocks and canvas elements by default
|
||||
*/
|
||||
filter?: (T | FilterFunc)[] | FilterFunc;
|
||||
}
|
||||
): BuiltInFilterModelMap[T][];
|
||||
search<T extends BuiltInFilterType = 'canvas' | 'block'>(
|
||||
bound: IBound,
|
||||
options: {
|
||||
strict?: boolean | undefined;
|
||||
useSet: true;
|
||||
filter?: (T | FilterFunc)[] | FilterFunc;
|
||||
}
|
||||
): Set<BuiltInFilterModelMap[T]>;
|
||||
search<T extends BuiltInFilterType = 'canvas' | 'block'>(
|
||||
bound: IBound,
|
||||
options: {
|
||||
strict?: boolean;
|
||||
useSet?: boolean;
|
||||
filter?: (T | FilterFunc)[] | FilterFunc;
|
||||
} = {
|
||||
useSet: false,
|
||||
}
|
||||
):
|
||||
| (GfxModel | GfxLocalElementModel)[]
|
||||
| Set<GfxModel | GfxLocalElementModel> {
|
||||
const strict = options.strict ?? false;
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
|
||||
const b = Bound.from(bound);
|
||||
const returnSet = options.useSet ?? false;
|
||||
const filterFunc =
|
||||
(Array.isArray(options.filter)
|
||||
? this._toFilterFunc(options.filter)
|
||||
: options.filter) ?? this._toFilterFunc(['canvas', 'block']);
|
||||
const results: Set<GfxModel | GfxLocalElementModel> = this._searchExternal(
|
||||
bound,
|
||||
{
|
||||
filterFunc,
|
||||
strict,
|
||||
}
|
||||
);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
const gridElements = this._getGrid(i, j);
|
||||
if (!gridElements) continue;
|
||||
for (const element of gridElements) {
|
||||
if (
|
||||
!(element as GfxPrimitiveElementModel).hidden &&
|
||||
filterFunc(element) &&
|
||||
(strict
|
||||
? b.contains(element.elementBound)
|
||||
: intersects(element.responseBound, b))
|
||||
) {
|
||||
results.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (returnSet) return results;
|
||||
|
||||
// sort elements in set based on index
|
||||
const sorted = Array.from(results).sort(compare);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
update(element: GfxModel | GfxLocalElementModel) {
|
||||
this.remove(element);
|
||||
this.add(element);
|
||||
}
|
||||
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
override unmounted(): void {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
const disposables = this._disposables;
|
||||
const { store } = this.std;
|
||||
const canBeRenderedAsGfxBlock = (
|
||||
block: BlockModel
|
||||
): block is GfxBlockElementModel => {
|
||||
return (
|
||||
block instanceof GfxBlockElementModel &&
|
||||
(block.parent?.role === 'root' ||
|
||||
block.parent instanceof SurfaceBlockModel)
|
||||
);
|
||||
};
|
||||
|
||||
disposables.add(
|
||||
store.slots.blockUpdated.subscribe(payload => {
|
||||
if (payload.type === 'add' && canBeRenderedAsGfxBlock(payload.model)) {
|
||||
this.add(payload.model);
|
||||
}
|
||||
if (payload.type === 'update') {
|
||||
const model = store.getModelById(payload.id);
|
||||
if (!model) return;
|
||||
|
||||
if (payload.props.key === 'xywh' && canBeRenderedAsGfxBlock(model)) {
|
||||
this.update(model);
|
||||
}
|
||||
}
|
||||
if (
|
||||
payload.type === 'delete' &&
|
||||
payload.model instanceof GfxBlockElementModel
|
||||
) {
|
||||
this.remove(payload.model);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
Object.values(store.blocks.peek()).forEach(block => {
|
||||
if (canBeRenderedAsGfxBlock(block.model)) {
|
||||
this.add(block.model);
|
||||
}
|
||||
});
|
||||
|
||||
const watchSurface = (surface: SurfaceBlockModel) => {
|
||||
let lastChildMap = new Map<string, number>(surface.childMap.peek());
|
||||
disposables.add(
|
||||
surface.childMap.subscribe(currentChildMap => {
|
||||
currentChildMap.forEach((_, id) => {
|
||||
if (lastChildMap.has(id)) {
|
||||
lastChildMap.delete(id);
|
||||
return;
|
||||
}
|
||||
});
|
||||
lastChildMap.forEach((_, id) => {
|
||||
const model = store.getModelById(id);
|
||||
if (model) {
|
||||
this.remove(model as GfxBlockElementModel);
|
||||
}
|
||||
});
|
||||
currentChildMap.forEach((_, id) => {
|
||||
const model = store.getModelById(id);
|
||||
if (model) {
|
||||
this.add(model as GfxBlockElementModel);
|
||||
}
|
||||
});
|
||||
lastChildMap = new Map(currentChildMap);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.elementAdded.subscribe(payload => {
|
||||
this.add(surface.getElementById(payload.id)!);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.elementRemoved.subscribe(payload => {
|
||||
this.remove(payload.model);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.elementUpdated.subscribe(payload => {
|
||||
if (
|
||||
payload.props['xywh'] ||
|
||||
payload.props['externalXYWH'] ||
|
||||
payload.props['responseExtension']
|
||||
) {
|
||||
this.update(surface.getElementById(payload.id)!);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.localElementAdded.subscribe(elm => {
|
||||
this.add(elm);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.localElementUpdated.subscribe(payload => {
|
||||
if (payload.props['xywh'] || payload.props['responseExtension']) {
|
||||
this.update(payload.model);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.localElementDeleted.subscribe(elm => {
|
||||
this.remove(elm);
|
||||
})
|
||||
);
|
||||
|
||||
surface.elementModels.forEach(model => {
|
||||
this.add(model);
|
||||
});
|
||||
surface.localElementModels.forEach(model => {
|
||||
this.add(model);
|
||||
});
|
||||
};
|
||||
|
||||
if (this.gfx.surface) {
|
||||
watchSurface(this.gfx.surface);
|
||||
} else {
|
||||
disposables.add(
|
||||
this.gfx.surface$.subscribe(surface => {
|
||||
if (surface) {
|
||||
watchSurface(surface);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
blocksuite/framework/std/src/gfx/identifiers.ts
Normal file
10
blocksuite/framework/std/src/gfx/identifiers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import { LifeCycleWatcherIdentifier } from '../identifier.js';
|
||||
import type { GfxController } from './controller.js';
|
||||
|
||||
export const gfxControllerKey = 'GfxController';
|
||||
|
||||
export const GfxControllerIdentifier = LifeCycleWatcherIdentifier(
|
||||
gfxControllerKey
|
||||
) as ServiceIdentifier<GfxController>;
|
||||
121
blocksuite/framework/std/src/gfx/index.ts
Normal file
121
blocksuite/framework/std/src/gfx/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export { generateKeyBetweenV2 } from '../utils/fractional-indexing.js';
|
||||
export {
|
||||
compare as compareLayer,
|
||||
renderableInEdgeless,
|
||||
SortOrder,
|
||||
} from '../utils/layer.js';
|
||||
export {
|
||||
batchAddChildren,
|
||||
batchRemoveChildren,
|
||||
canSafeAddToContainer,
|
||||
descendantElementsImpl,
|
||||
getTopElements,
|
||||
hasDescendantElementImpl,
|
||||
} from '../utils/tree.js';
|
||||
export { GfxController } from './controller.js';
|
||||
export type { CursorType, StandardCursor } from './cursor.js';
|
||||
export { GfxExtension, GfxExtensionIdentifier } from './extension.js';
|
||||
export { GridManager } from './grid.js';
|
||||
export { GfxControllerIdentifier } from './identifiers.js';
|
||||
export type {
|
||||
BoxSelectionContext,
|
||||
DragEndContext,
|
||||
DragExtensionInitializeContext,
|
||||
DragInitializationOption,
|
||||
DragMoveContext,
|
||||
DragStartContext,
|
||||
ExtensionDragEndContext,
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
GfxInteractivityContext,
|
||||
GfxViewInteractionConfig,
|
||||
ResizeConstraint,
|
||||
ResizeEndContext,
|
||||
ResizeHandle,
|
||||
ResizeMoveContext,
|
||||
ResizeStartContext,
|
||||
RotateConstraint,
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectContext,
|
||||
} from './interactivity/index.js';
|
||||
export {
|
||||
GfxViewEventManager,
|
||||
GfxViewInteractionExtension,
|
||||
InteractivityExtension,
|
||||
InteractivityIdentifier,
|
||||
InteractivityManager,
|
||||
} from './interactivity/index.js';
|
||||
export { LayerManager, type ReorderingDirection } from './layer.js';
|
||||
export type {
|
||||
GfxCompatibleInterface,
|
||||
GfxElementGeometry,
|
||||
GfxGroupCompatibleInterface,
|
||||
PointTestOptions,
|
||||
} from './model/base.js';
|
||||
export {
|
||||
gfxGroupCompatibleSymbol,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from './model/base.js';
|
||||
export {
|
||||
GfxBlockElementModel,
|
||||
type GfxCommonBlockProps,
|
||||
GfxCompatibleBlockModel as GfxCompatible,
|
||||
type GfxCompatibleProps,
|
||||
} from './model/gfx-block-model.js';
|
||||
export { type GfxModel, isPrimitiveModel } from './model/model.js';
|
||||
export {
|
||||
convert,
|
||||
convertProps,
|
||||
derive,
|
||||
field,
|
||||
getDerivedProps,
|
||||
getFieldPropsSet,
|
||||
initializeObservers,
|
||||
initializeWatchers,
|
||||
local,
|
||||
observe,
|
||||
updateDerivedProps,
|
||||
watch,
|
||||
} from './model/surface/decorators/index.js';
|
||||
export {
|
||||
type BaseElementProps,
|
||||
GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
type SerializedElement,
|
||||
} from './model/surface/element-model.js';
|
||||
export {
|
||||
GfxLocalElementModel,
|
||||
prop,
|
||||
} from './model/surface/local-element-model.js';
|
||||
export {
|
||||
SURFACE_TEXT_UNIQ_IDENTIFIER,
|
||||
SURFACE_YMAP_UNIQ_IDENTIFIER,
|
||||
SurfaceBlockModel,
|
||||
type SurfaceBlockProps,
|
||||
type SurfaceMiddleware,
|
||||
} from './model/surface/surface-model.js';
|
||||
export { measureOperation } from './perf.js';
|
||||
export { createRafCoalescer, type RafCoalescer } from './raf-coalescer.js';
|
||||
export { GfxSelectionManager } from './selection.js';
|
||||
export {
|
||||
SurfaceMiddlewareBuilder,
|
||||
SurfaceMiddlewareExtension,
|
||||
} from './surface-middleware.js';
|
||||
export {
|
||||
BaseTool,
|
||||
type ToolOptions,
|
||||
type ToolOptionWithType,
|
||||
type ToolType,
|
||||
} from './tool/tool.js';
|
||||
export { MouseButton, ToolController } from './tool/tool-controller.js';
|
||||
export {
|
||||
type EventsHandlerMap,
|
||||
GfxElementModelView,
|
||||
type SupportedEvent,
|
||||
} from './view/view.js';
|
||||
export { ViewManager } from './view/view-manager.js';
|
||||
export * from './viewport.js';
|
||||
export { GfxViewportElement } from './viewport-element.js';
|
||||
export { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
48
blocksuite/framework/std/src/gfx/interactivity/event.ts
Normal file
48
blocksuite/framework/std/src/gfx/interactivity/event.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { PointerEventState, UIEventState } from '../../event';
|
||||
|
||||
export type SupportedEvents =
|
||||
| 'click'
|
||||
| 'dblclick'
|
||||
| 'pointerdown'
|
||||
| 'pointerenter'
|
||||
| 'pointerleave'
|
||||
| 'pointermove'
|
||||
| 'pointerup'
|
||||
| 'dragstart'
|
||||
| 'dragmove'
|
||||
| 'dragend';
|
||||
|
||||
export type GfxInteractivityContext<
|
||||
EventState extends UIEventState = PointerEventState,
|
||||
RawEvent extends Event = EventState['event'],
|
||||
> = {
|
||||
event: EventState;
|
||||
|
||||
/**
|
||||
* The raw dom event.
|
||||
*/
|
||||
raw: RawEvent;
|
||||
|
||||
/**
|
||||
* Prevent the default gfx interaction
|
||||
*/
|
||||
preventDefault: () => void;
|
||||
};
|
||||
|
||||
export const createInteractionContext = (event: PointerEventState) => {
|
||||
let preventDefaultState = false;
|
||||
|
||||
return {
|
||||
context: {
|
||||
event,
|
||||
raw: event.raw,
|
||||
preventDefault: () => {
|
||||
preventDefaultState = true;
|
||||
event.raw.preventDefault();
|
||||
},
|
||||
},
|
||||
get preventDefaultState() {
|
||||
return preventDefaultState;
|
||||
},
|
||||
};
|
||||
};
|
||||
211
blocksuite/framework/std/src/gfx/interactivity/extension/base.ts
Normal file
211
blocksuite/framework/std/src/gfx/interactivity/extension/base.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { GfxController } from '../../controller.js';
|
||||
import { GfxControllerIdentifier } from '../../identifiers.js';
|
||||
import type { GfxModel } from '../../model/model.js';
|
||||
import type { GfxInteractivityContext, SupportedEvents } from '../event.js';
|
||||
import type { ExtensionElementsCloneContext } from '../types/clone.js';
|
||||
import type {
|
||||
DragExtensionInitializeContext,
|
||||
ExtensionDragEndContext,
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
} from '../types/drag.js';
|
||||
import type {
|
||||
ExtensionElementResizeContext,
|
||||
ExtensionElementResizeEndContext,
|
||||
ExtensionElementResizeMoveContext,
|
||||
ExtensionElementResizeStartContext,
|
||||
} from '../types/resize.js';
|
||||
import type { ExtensionElementSelectContext } from '../types/select.js';
|
||||
|
||||
export const InteractivityExtensionIdentifier =
|
||||
createIdentifier<InteractivityExtension>('interactivity-extension');
|
||||
|
||||
export class InteractivityExtension extends Extension {
|
||||
static key: string;
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
event: Omit<InteractivityEventAPI, 'emit'> = new InteractivityEventAPI();
|
||||
|
||||
action: Omit<InteractivityActionAPI, 'emit'> = new InteractivityActionAPI();
|
||||
|
||||
constructor(protected readonly gfx: GfxController) {
|
||||
super();
|
||||
}
|
||||
|
||||
mounted() {}
|
||||
|
||||
/**
|
||||
* Override this method should call `super.unmounted()`
|
||||
*/
|
||||
unmounted() {
|
||||
this.event.destroy();
|
||||
this.action.destroy();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'key is not defined in the InteractivityExtension'
|
||||
);
|
||||
}
|
||||
|
||||
di.add(
|
||||
this as unknown as { new (gfx: GfxController): InteractivityExtension },
|
||||
[GfxControllerIdentifier]
|
||||
);
|
||||
di.addImpl(InteractivityExtensionIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class InteractivityEventAPI {
|
||||
private readonly _handlersMap = new Map<
|
||||
SupportedEvents,
|
||||
((evt: GfxInteractivityContext) => void)[]
|
||||
>();
|
||||
|
||||
on(
|
||||
eventName: SupportedEvents,
|
||||
handler: (evt: GfxInteractivityContext) => void
|
||||
) {
|
||||
const handlers = this._handlersMap.get(eventName) ?? [];
|
||||
handlers.push(handler);
|
||||
this._handlersMap.set(eventName, handlers);
|
||||
|
||||
return () => {
|
||||
const idx = handlers.indexOf(handler);
|
||||
|
||||
if (idx > -1) {
|
||||
handlers.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName: SupportedEvents, evt: GfxInteractivityContext) {
|
||||
const handlers = this._handlersMap.get(eventName);
|
||||
if (!handlers) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of handlers) {
|
||||
handler(evt);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._handlersMap.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export type ActionContextMap = {
|
||||
dragInitialize: {
|
||||
context: DragExtensionInitializeContext;
|
||||
returnType: {
|
||||
onDragStart?: (context: ExtensionDragStartContext) => void;
|
||||
onDragMove?: (context: ExtensionDragMoveContext) => void;
|
||||
onDragEnd?: (context: ExtensionDragEndContext) => void;
|
||||
clear?: () => void;
|
||||
};
|
||||
};
|
||||
elementsClone: {
|
||||
context: ExtensionElementsCloneContext;
|
||||
returnType: Promise<
|
||||
| {
|
||||
elements: GfxModel[];
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
};
|
||||
elementResize: {
|
||||
context: ExtensionElementResizeContext;
|
||||
returnType: {
|
||||
onResizeStart?: (context: ExtensionElementResizeStartContext) => void;
|
||||
onResizeMove?: (context: ExtensionElementResizeMoveContext) => void;
|
||||
onResizeEnd?: (context: ExtensionElementResizeEndContext) => void;
|
||||
};
|
||||
};
|
||||
elementSelect: {
|
||||
context: ExtensionElementSelectContext;
|
||||
returnType: void;
|
||||
};
|
||||
};
|
||||
|
||||
export class InteractivityActionAPI {
|
||||
private readonly _handlers: Partial<{
|
||||
[K in keyof ActionContextMap]: (
|
||||
ctx: ActionContextMap[K]['context']
|
||||
) => ActionContextMap[K]['returnType'];
|
||||
}> = {};
|
||||
|
||||
onDragInitialize(
|
||||
handler: (
|
||||
ctx: ActionContextMap['dragInitialize']['context']
|
||||
) => ActionContextMap['dragInitialize']['returnType']
|
||||
) {
|
||||
this._handlers['dragInitialize'] = handler;
|
||||
|
||||
return () => {
|
||||
delete this._handlers['dragInitialize'];
|
||||
};
|
||||
}
|
||||
|
||||
onElementResize(
|
||||
handler: (
|
||||
ctx: ActionContextMap['elementResize']['context']
|
||||
) => ActionContextMap['elementResize']['returnType']
|
||||
) {
|
||||
this._handlers['elementResize'] = handler;
|
||||
|
||||
return () => {
|
||||
return delete this._handlers['elementResize'];
|
||||
};
|
||||
}
|
||||
|
||||
onRequestElementsClone(
|
||||
handler: (
|
||||
ctx: ActionContextMap['elementsClone']['context']
|
||||
) => ActionContextMap['elementsClone']['returnType']
|
||||
) {
|
||||
this._handlers['elementsClone'] = handler;
|
||||
|
||||
return () => {
|
||||
return delete this._handlers['elementsClone'];
|
||||
};
|
||||
}
|
||||
|
||||
onElementSelect(
|
||||
handler: (
|
||||
ctx: ActionContextMap['elementSelect']['context']
|
||||
) => ActionContextMap['elementSelect']['returnType']
|
||||
) {
|
||||
this._handlers['elementSelect'] = handler;
|
||||
|
||||
return () => {
|
||||
return delete this._handlers['elementSelect'];
|
||||
};
|
||||
}
|
||||
|
||||
emit<K extends keyof ActionContextMap>(
|
||||
event: K,
|
||||
context: ActionContextMap[K]['context']
|
||||
): ActionContextMap[K]['returnType'] | undefined {
|
||||
const handler = this._handlers[event];
|
||||
|
||||
return handler?.(context);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const key in this._handlers) {
|
||||
delete this._handlers[key as keyof typeof this._handlers];
|
||||
}
|
||||
}
|
||||
}
|
||||
134
blocksuite/framework/std/src/gfx/interactivity/extension/view.ts
Normal file
134
blocksuite/framework/std/src/gfx/interactivity/extension/view.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { BlockStdScope } from '../../../scope';
|
||||
import type { GfxBlockComponent } from '../../../view';
|
||||
import type { GfxController, GfxModel } from '../..';
|
||||
import type { GfxElementModelView } from '../../view/view';
|
||||
import type {
|
||||
BeforeResizeContext,
|
||||
BeforeRotateContext,
|
||||
ResizeConstraint,
|
||||
ResizeEndContext,
|
||||
ResizeMoveContext,
|
||||
ResizeStartContext,
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectableContext,
|
||||
SelectContext,
|
||||
} from '../types/view';
|
||||
|
||||
type ExtendedViewContext<
|
||||
T extends GfxBlockComponent | GfxElementModelView,
|
||||
Context,
|
||||
DefaultReturnType = void,
|
||||
> = {
|
||||
/**
|
||||
* The default function of the interaction.
|
||||
* If the interaction is handled by the extension, the default function will not be executed.
|
||||
* But extension can choose to call the default function by `context.default(context)` if needed.
|
||||
*/
|
||||
default: (context: Context) => DefaultReturnType;
|
||||
|
||||
model: T['model'];
|
||||
|
||||
view: T;
|
||||
};
|
||||
|
||||
type ViewInteractionHandleContext<
|
||||
T extends GfxBlockComponent | GfxElementModelView,
|
||||
> = {
|
||||
std: BlockStdScope;
|
||||
gfx: GfxController;
|
||||
view: T;
|
||||
model: T['model'];
|
||||
|
||||
/**
|
||||
* Used to add an element to resize list.
|
||||
* @param model
|
||||
*/
|
||||
add(element: GfxModel): void;
|
||||
|
||||
/**
|
||||
* Used to remove an element from resize list.
|
||||
* @param element
|
||||
*/
|
||||
delete(element: GfxModel): void;
|
||||
};
|
||||
|
||||
export type GfxViewInteractionConfig<
|
||||
T extends GfxBlockComponent | GfxElementModelView =
|
||||
| GfxBlockComponent
|
||||
| GfxElementModelView,
|
||||
> = {
|
||||
readonly resizeConstraint?: ResizeConstraint;
|
||||
|
||||
/**
|
||||
* The function that will be called when the view is resized.
|
||||
* You can add or delete the resize element before resize starts in this function.,
|
||||
* And return handlers to customize the resize behavior.
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
handleResize?: (context: ViewInteractionHandleContext<T>) => {
|
||||
/**
|
||||
* Called before resize starts. When this method is called, the resize elements are confirmed and will not be changed.
|
||||
* You can set the resize constraint in this method.
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
beforeResize?: (context: BeforeResizeContext) => void;
|
||||
onResizeStart?(
|
||||
context: ResizeStartContext & ExtendedViewContext<T, ResizeStartContext>
|
||||
): void;
|
||||
onResizeMove?(
|
||||
context: ResizeMoveContext & ExtendedViewContext<T, ResizeMoveContext>
|
||||
): void;
|
||||
onResizeEnd?(
|
||||
context: ResizeEndContext & ExtendedViewContext<T, ResizeEndContext>
|
||||
): void;
|
||||
};
|
||||
|
||||
handleRotate?: (context: ViewInteractionHandleContext<T>) => {
|
||||
beforeRotate?: (context: BeforeRotateContext) => void;
|
||||
onRotateStart?(
|
||||
context: RotateStartContext & ExtendedViewContext<T, RotateStartContext>
|
||||
): void;
|
||||
onRotateMove?(
|
||||
context: RotateMoveContext & ExtendedViewContext<T, RotateMoveContext>
|
||||
): void;
|
||||
onRotateEnd?(
|
||||
context: RotateEndContext & ExtendedViewContext<T, RotateEndContext>
|
||||
): void;
|
||||
};
|
||||
|
||||
handleSelection?: (
|
||||
context: Omit<ViewInteractionHandleContext<T>, 'add' | 'delete'>
|
||||
) => {
|
||||
selectable?: (
|
||||
context: SelectableContext &
|
||||
ExtendedViewContext<T, SelectableContext, boolean>
|
||||
) => boolean;
|
||||
onSelect?: (
|
||||
context: SelectContext &
|
||||
ExtendedViewContext<T, SelectContext, boolean | void>
|
||||
) => boolean | void;
|
||||
};
|
||||
};
|
||||
|
||||
export const GfxViewInteractionIdentifier =
|
||||
createIdentifier<GfxViewInteractionConfig>('GfxViewInteraction');
|
||||
|
||||
export function GfxViewInteractionExtension<
|
||||
T extends GfxBlockComponent | GfxElementModelView,
|
||||
>(viewType: string, config: GfxViewInteractionConfig<T>): ExtensionType {
|
||||
return {
|
||||
setup(di) {
|
||||
di.addImpl(
|
||||
GfxViewInteractionIdentifier(viewType),
|
||||
() => config as GfxViewInteractionConfig
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import last from 'lodash-es/last';
|
||||
|
||||
import type { PointerEventState } from '../../event';
|
||||
import type { GfxController } from '../controller.js';
|
||||
import type { GfxElementModelView, SupportedEvent } from '../view/view.js';
|
||||
|
||||
export class GfxViewEventManager {
|
||||
private _hoveredElementsStack: GfxElementModelView[] = [];
|
||||
private _draggingElement: GfxElementModelView | null = null;
|
||||
|
||||
private _callInReverseOrder(
|
||||
callback: (view: GfxElementModelView) => void,
|
||||
arr = this._hoveredElementsStack
|
||||
) {
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
const view = arr[i];
|
||||
|
||||
callback(view);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private readonly gfx: GfxController) {}
|
||||
|
||||
dispatch(eventName: SupportedEvent, evt: PointerEventState) {
|
||||
if (eventName === 'pointermove') {
|
||||
this._handlePointerMove(evt);
|
||||
return false;
|
||||
} else if (eventName.startsWith('drag')) {
|
||||
return this._handleDrag(
|
||||
eventName as 'dragstart' | 'dragend' | 'dragmove',
|
||||
evt
|
||||
);
|
||||
} else {
|
||||
return last(this._hoveredElementsStack)?.dispatch(eventName, evt);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDrag(
|
||||
evtName: 'dragstart' | 'dragend' | 'dragmove',
|
||||
_evt: PointerEventState
|
||||
): boolean {
|
||||
switch (evtName) {
|
||||
case 'dragstart': {
|
||||
if (this._draggingElement) {
|
||||
this._draggingElement.dispatch('dragend', _evt);
|
||||
}
|
||||
this._draggingElement = last(this._hoveredElementsStack) ?? null;
|
||||
return this._draggingElement?.dispatch('dragstart', _evt) ?? false;
|
||||
}
|
||||
case 'dragmove': {
|
||||
return this._draggingElement?.dispatch('dragmove', _evt) ?? false;
|
||||
}
|
||||
case 'dragend': {
|
||||
const dispatched =
|
||||
this._draggingElement?.dispatch('dragend', _evt) ?? false;
|
||||
this._draggingElement = null;
|
||||
return dispatched;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handlePointerMove(_evt: PointerEventState): void {
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(_evt.x, _evt.y);
|
||||
const hoveredElmViews = this.gfx.grid
|
||||
.search(new Bound(x - 5, y - 5, 10, 10), {
|
||||
filter: ['canvas', 'local'],
|
||||
})
|
||||
.reduce((pre, model) => {
|
||||
if (
|
||||
model.includesPoint(
|
||||
x,
|
||||
y,
|
||||
{
|
||||
hitThreshold: 10,
|
||||
responsePadding: [5, 5],
|
||||
},
|
||||
this.gfx.std.host
|
||||
) ||
|
||||
('externalBound' in model
|
||||
? model.externalBound?.isPointInBound([x, y])
|
||||
: false)
|
||||
) {
|
||||
const view = this.gfx.view.get(model) as GfxElementModelView | null;
|
||||
|
||||
view && pre.push(view);
|
||||
}
|
||||
|
||||
return pre;
|
||||
}, [] as GfxElementModelView[]);
|
||||
|
||||
const currentStackedViews = new Set(this._hoveredElementsStack);
|
||||
const visited = new Set<GfxElementModelView>();
|
||||
|
||||
this._callInReverseOrder(view => {
|
||||
if (currentStackedViews.has(view)) {
|
||||
visited.add(view);
|
||||
view.dispatch('pointermove', _evt);
|
||||
} else {
|
||||
view.dispatch('pointerenter', _evt);
|
||||
}
|
||||
}, hoveredElmViews);
|
||||
this._callInReverseOrder(
|
||||
view => !visited.has(view) && view.dispatch('pointerleave', _evt)
|
||||
);
|
||||
this._hoveredElementsStack = hoveredElmViews;
|
||||
}
|
||||
}
|
||||
33
blocksuite/framework/std/src/gfx/interactivity/index.ts
Normal file
33
blocksuite/framework/std/src/gfx/interactivity/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type { GfxInteractivityContext } from './event.js';
|
||||
export { InteractivityExtension } from './extension/base.js';
|
||||
export {
|
||||
type GfxViewInteractionConfig,
|
||||
GfxViewInteractionExtension,
|
||||
GfxViewInteractionIdentifier,
|
||||
} from './extension/view.js';
|
||||
export { GfxViewEventManager } from './gfx-view-event-handler.js';
|
||||
export { InteractivityIdentifier, InteractivityManager } from './manager.js';
|
||||
export { type ResizeHandle } from './resize/manager.js';
|
||||
export type {
|
||||
DragExtensionInitializeContext,
|
||||
DragInitializationOption,
|
||||
ExtensionDragEndContext,
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
} from './types/drag.js';
|
||||
export type {
|
||||
BoxSelectionContext,
|
||||
DragEndContext,
|
||||
DragMoveContext,
|
||||
DragStartContext,
|
||||
GfxViewTransformInterface,
|
||||
ResizeConstraint,
|
||||
ResizeEndContext,
|
||||
ResizeMoveContext,
|
||||
ResizeStartContext,
|
||||
RotateConstraint,
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectContext,
|
||||
} from './types/view.js';
|
||||
1169
blocksuite/framework/std/src/gfx/interactivity/manager.ts
Normal file
1169
blocksuite/framework/std/src/gfx/interactivity/manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
657
blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts
Normal file
657
blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
Bound,
|
||||
getCommonBoundWithRotation,
|
||||
type IBound,
|
||||
type IPoint,
|
||||
type IVec,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
import type { GfxController } from '../..';
|
||||
import type { GfxModel } from '../../model/model';
|
||||
|
||||
export type ResizeHandle =
|
||||
| 'top-left'
|
||||
| 'top'
|
||||
| 'top-right'
|
||||
| 'right'
|
||||
| 'bottom-right'
|
||||
| 'bottom'
|
||||
| 'bottom-left'
|
||||
| 'left';
|
||||
|
||||
export const DEFAULT_HANDLES: ResizeHandle[] = [
|
||||
'top-left',
|
||||
'top-right',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'bottom',
|
||||
];
|
||||
|
||||
type ReadonlyIBound = Readonly<Required<IBound>>;
|
||||
|
||||
export interface OptionResize {
|
||||
elements: GfxModel[];
|
||||
handle: ResizeHandle;
|
||||
lockRatio: boolean;
|
||||
event: PointerEvent;
|
||||
onResizeMove: (payload: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
|
||||
originalBound: IBound;
|
||||
|
||||
handleSign: IPoint;
|
||||
|
||||
handlePos: IVec;
|
||||
currentHandlePos: IVec;
|
||||
|
||||
lockRatio: boolean;
|
||||
}) => { scaleX: number; scaleY: number };
|
||||
onResizeUpdate: (payload: {
|
||||
lockRatio: boolean;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
data: {
|
||||
model: GfxModel;
|
||||
originalBound: Bound;
|
||||
newBound: Bound;
|
||||
lockRatio: boolean;
|
||||
matrix: DOMMatrix;
|
||||
}[];
|
||||
}) => void;
|
||||
onResizeStart?: (payload: {
|
||||
handlePos: IVec;
|
||||
handleSign: IPoint;
|
||||
data: { model: GfxModel }[];
|
||||
}) => void;
|
||||
onResizeEnd?: (payload: {
|
||||
handlePos: IVec;
|
||||
handleSign: IPoint;
|
||||
data: { model: GfxModel }[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export type RotateOption = {
|
||||
elements: GfxModel[];
|
||||
event: PointerEvent;
|
||||
|
||||
onRotateUpdate: (payload: {
|
||||
delta: number;
|
||||
data: {
|
||||
model: GfxModel;
|
||||
newBound: Bound;
|
||||
originalBound: Bound;
|
||||
originalRotate: number;
|
||||
newRotate: number;
|
||||
matrix: DOMMatrix;
|
||||
}[];
|
||||
}) => void;
|
||||
|
||||
onRotateStart?: (payload: { data: { model: GfxModel }[] }) => void;
|
||||
|
||||
onRotateEnd?: (payload: { data: { model: GfxModel }[] }) => void;
|
||||
};
|
||||
|
||||
export class ResizeController {
|
||||
private readonly gfx: GfxController;
|
||||
|
||||
get host() {
|
||||
return this.gfx.std.host;
|
||||
}
|
||||
|
||||
constructor(option: { gfx: GfxController }) {
|
||||
this.gfx = option.gfx;
|
||||
}
|
||||
|
||||
getCoordsTransform(originalBound: IBound, handle: ResizeHandle) {
|
||||
const { x: xSign, y: ySign } = this.getHandleSign(handle);
|
||||
const pivot = new DOMPoint(
|
||||
originalBound.x + ((-xSign + 1) / 2) * originalBound.w,
|
||||
originalBound.y + ((-ySign + 1) / 2) * originalBound.h
|
||||
);
|
||||
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
|
||||
const toLocalRotatedM = new DOMMatrix()
|
||||
.translate(-pivot.x, -pivot.y)
|
||||
.translate(
|
||||
originalBound.w / 2 + originalBound.x,
|
||||
originalBound.h / 2 + originalBound.y
|
||||
)
|
||||
.rotate(-(originalBound.rotate ?? 0))
|
||||
.translate(
|
||||
-(originalBound.w / 2 + originalBound.x),
|
||||
-(originalBound.h / 2 + originalBound.y)
|
||||
);
|
||||
|
||||
const toLocal = (p: DOMPoint, withRotation: boolean = false) =>
|
||||
p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM);
|
||||
const toModel = (p: DOMPoint) =>
|
||||
p.matrixTransform(toLocalRotatedM.inverse());
|
||||
|
||||
const handlePos = toModel(
|
||||
new DOMPoint(originalBound.w * xSign, originalBound.h * ySign)
|
||||
);
|
||||
|
||||
return {
|
||||
xSign,
|
||||
ySign,
|
||||
originalBound,
|
||||
toLocalM,
|
||||
toLocalRotatedM,
|
||||
toLocal,
|
||||
toModel,
|
||||
handlePos: [handlePos.x, handlePos.y] as IVec,
|
||||
};
|
||||
}
|
||||
|
||||
getScaleFromDelta(
|
||||
transform: ReturnType<ResizeController['getCoordsTransform']>,
|
||||
delta: { dx: number; dy: number },
|
||||
handleStartPos: IVec,
|
||||
lockRatio: boolean
|
||||
) {
|
||||
const { originalBound, xSign, ySign, toModel, toLocal } = transform;
|
||||
const currentPos = toLocal(
|
||||
new DOMPoint(handleStartPos[0] + delta.dx, handleStartPos[1] + delta.dy),
|
||||
true
|
||||
);
|
||||
|
||||
let scaleX = xSign ? currentPos.x / (originalBound.w * xSign) : 1;
|
||||
let scaleY = ySign ? currentPos.y / (originalBound.h * ySign) : 1;
|
||||
|
||||
if (lockRatio) {
|
||||
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
|
||||
scaleX = Math.sign(scaleX) * min;
|
||||
scaleY = Math.sign(scaleY) * min;
|
||||
}
|
||||
|
||||
const finalHandlePos = toModel(
|
||||
new DOMPoint(
|
||||
originalBound.w * xSign * scaleX,
|
||||
originalBound.h * ySign * scaleY
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
scaleX,
|
||||
scaleY,
|
||||
handlePos: [finalHandlePos.x, finalHandlePos.y] as IVec,
|
||||
};
|
||||
}
|
||||
|
||||
getScaleMatrix(
|
||||
{ scaleX, scaleY }: { scaleX: number; scaleY: number },
|
||||
lockRatio: boolean
|
||||
) {
|
||||
if (lockRatio) {
|
||||
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
|
||||
scaleX = Math.sign(scaleX) * min;
|
||||
scaleY = Math.sign(scaleY) * min;
|
||||
}
|
||||
|
||||
return {
|
||||
scaleX,
|
||||
scaleY,
|
||||
scaleM: new DOMMatrix().scaleSelf(scaleX, scaleY),
|
||||
};
|
||||
}
|
||||
|
||||
startResize(options: OptionResize) {
|
||||
const {
|
||||
elements,
|
||||
handle,
|
||||
onResizeStart,
|
||||
onResizeMove,
|
||||
onResizeUpdate,
|
||||
onResizeEnd,
|
||||
event,
|
||||
} = options;
|
||||
|
||||
const originals: ReadonlyIBound[] = elements.map(el => ({
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
w: el.w,
|
||||
h: el.h,
|
||||
rotate: el.rotate,
|
||||
}));
|
||||
const originalBound: IBound =
|
||||
originals.length > 1
|
||||
? getCommonBoundWithRotation(originals)
|
||||
: {
|
||||
x: originals[0].x,
|
||||
y: originals[0].y,
|
||||
w: originals[0].w,
|
||||
h: originals[0].h,
|
||||
rotate: originals[0].rotate,
|
||||
};
|
||||
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
]);
|
||||
const transform = this.getCoordsTransform(originalBound, handle);
|
||||
const handleSign = {
|
||||
x: transform.xSign,
|
||||
y: transform.ySign,
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const currPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
]);
|
||||
let delta = {
|
||||
dx: currPt[0] - startPt[0],
|
||||
dy: currPt[1] - startPt[1],
|
||||
};
|
||||
const shouldLockRatio =
|
||||
options.lockRatio || e.shiftKey || elements.length > 1;
|
||||
const {
|
||||
scaleX,
|
||||
scaleY,
|
||||
handlePos: currentHandlePos,
|
||||
} = this.getScaleFromDelta(
|
||||
transform,
|
||||
delta,
|
||||
transform.handlePos,
|
||||
shouldLockRatio
|
||||
);
|
||||
|
||||
const scale = onResizeMove({
|
||||
scaleX,
|
||||
scaleY,
|
||||
|
||||
originalBound,
|
||||
|
||||
handleSign,
|
||||
|
||||
handlePos: transform.handlePos,
|
||||
currentHandlePos,
|
||||
|
||||
lockRatio: shouldLockRatio,
|
||||
});
|
||||
const scaleInfo = this.getScaleMatrix(scale, shouldLockRatio);
|
||||
|
||||
if (elements.length === 1) {
|
||||
this.resizeSingle(
|
||||
originals[0],
|
||||
elements[0],
|
||||
shouldLockRatio,
|
||||
transform,
|
||||
scaleInfo,
|
||||
onResizeUpdate
|
||||
);
|
||||
} else {
|
||||
this.resizeMulti(
|
||||
originals,
|
||||
elements,
|
||||
transform,
|
||||
scaleInfo,
|
||||
onResizeUpdate
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onResizeStart?.({
|
||||
handleSign,
|
||||
handlePos: transform.handlePos,
|
||||
data: elements.map(model => ({ model })),
|
||||
});
|
||||
|
||||
const onPointerUp = () => {
|
||||
this.host.removeEventListener('pointermove', onPointerMove);
|
||||
this.host.removeEventListener('pointerup', onPointerUp);
|
||||
|
||||
onResizeEnd?.({
|
||||
handleSign,
|
||||
handlePos: transform.handlePos,
|
||||
data: elements.map(model => ({ model })),
|
||||
});
|
||||
};
|
||||
|
||||
this.host.addEventListener('pointermove', onPointerMove);
|
||||
this.host.addEventListener('pointerup', onPointerUp);
|
||||
}
|
||||
|
||||
private resizeSingle(
|
||||
orig: ReadonlyIBound,
|
||||
model: GfxModel,
|
||||
lockRatio: boolean,
|
||||
transform: ReturnType<typeof ResizeController.prototype.getCoordsTransform>,
|
||||
scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
|
||||
updateCallback: OptionResize['onResizeUpdate']
|
||||
) {
|
||||
const { toLocalM, toLocalRotatedM, toLocal, toModel } = transform;
|
||||
const { scaleX, scaleY, scaleM } = scale;
|
||||
|
||||
const [visualTopLeft, visualBottomRight] = [
|
||||
new DOMPoint(orig.x, orig.y),
|
||||
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
|
||||
].map(p => {
|
||||
const localP = toLocal(p, false);
|
||||
const scaledP = localP.matrixTransform(scaleM);
|
||||
|
||||
return toModel(scaledP);
|
||||
});
|
||||
|
||||
const center = {
|
||||
x:
|
||||
Math.min(visualTopLeft.x, visualBottomRight.x) +
|
||||
Math.abs(visualBottomRight.x - visualTopLeft.x) / 2,
|
||||
y:
|
||||
Math.min(visualTopLeft.y, visualBottomRight.y) +
|
||||
Math.abs(visualBottomRight.y - visualTopLeft.y) / 2,
|
||||
};
|
||||
|
||||
const restoreM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(-orig.rotate)
|
||||
.translate(-center.x, -center.y);
|
||||
|
||||
// only used to provide the matrix information
|
||||
const finalM = restoreM
|
||||
.multiply(toLocalRotatedM.inverse())
|
||||
.multiply(scaleM)
|
||||
.multiply(toLocalM);
|
||||
|
||||
const [topLeft, bottomRight] = [visualTopLeft, visualBottomRight].map(p => {
|
||||
return p.matrixTransform(restoreM);
|
||||
});
|
||||
|
||||
updateCallback({
|
||||
lockRatio,
|
||||
scaleX,
|
||||
scaleY,
|
||||
data: [
|
||||
{
|
||||
model: model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound: new Bound(
|
||||
Math.min(topLeft.x, bottomRight.x),
|
||||
Math.min(bottomRight.y, topLeft.y),
|
||||
Math.abs(bottomRight.x - topLeft.x),
|
||||
Math.abs(bottomRight.y - topLeft.y)
|
||||
),
|
||||
lockRatio: lockRatio,
|
||||
matrix: finalM,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private resizeMulti(
|
||||
originals: ReadonlyIBound[],
|
||||
elements: GfxModel[],
|
||||
transform: ReturnType<ResizeController['getCoordsTransform']>,
|
||||
scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
|
||||
updateCallback: OptionResize['onResizeUpdate']
|
||||
) {
|
||||
const { toLocalM } = transform;
|
||||
const { scaleX, scaleY, scaleM } = scale;
|
||||
|
||||
const data = elements.map((model, i) => {
|
||||
const orig = originals[i];
|
||||
const finalM = new DOMMatrix()
|
||||
.multiply(toLocalM.inverse())
|
||||
.multiply(scaleM)
|
||||
.multiply(toLocalM);
|
||||
const [topLeft, bottomRight] = [
|
||||
new DOMPoint(orig.x, orig.y),
|
||||
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
|
||||
].map(p => {
|
||||
return p.matrixTransform(finalM);
|
||||
});
|
||||
|
||||
const newBound = new Bound(
|
||||
Math.min(topLeft.x, bottomRight.x),
|
||||
Math.min(bottomRight.y, topLeft.y),
|
||||
Math.abs(bottomRight.x - topLeft.x),
|
||||
Math.abs(bottomRight.y - topLeft.y)
|
||||
);
|
||||
|
||||
return {
|
||||
model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound,
|
||||
lockRatio: true,
|
||||
matrix: finalM,
|
||||
};
|
||||
});
|
||||
|
||||
updateCallback({ lockRatio: true, scaleX, scaleY, data });
|
||||
}
|
||||
|
||||
startRotate(option: RotateOption) {
|
||||
const { event, elements, onRotateUpdate } = option;
|
||||
|
||||
const originals: ReadonlyIBound[] = elements.map(el => ({
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
w: el.w,
|
||||
h: el.h,
|
||||
rotate: el.rotate,
|
||||
}));
|
||||
|
||||
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
]);
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const currentPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
]);
|
||||
const snap = e.shiftKey;
|
||||
|
||||
if (elements.length > 1) {
|
||||
this.rotateMulti({
|
||||
origs: originals,
|
||||
models: elements,
|
||||
startPt,
|
||||
currentPt,
|
||||
snap,
|
||||
onRotateUpdate,
|
||||
});
|
||||
} else {
|
||||
this.rotateSingle({
|
||||
orig: originals[0],
|
||||
model: elements[0],
|
||||
startPt,
|
||||
currentPt,
|
||||
snap,
|
||||
onRotateUpdate,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
this.host.removeEventListener('pointermove', onPointerMove);
|
||||
this.host.removeEventListener('pointerup', onPointerUp);
|
||||
this.host.removeEventListener('pointercancel', onPointerUp);
|
||||
|
||||
option.onRotateEnd?.({ data: elements.map(model => ({ model })) });
|
||||
};
|
||||
|
||||
option.onRotateStart?.({ data: elements.map(model => ({ model })) });
|
||||
|
||||
this.host.addEventListener('pointermove', onPointerMove, false);
|
||||
this.host.addEventListener('pointerup', onPointerUp, false);
|
||||
this.host.addEventListener('pointercancel', onPointerUp, false);
|
||||
}
|
||||
|
||||
private getNormalizedAngle(y: number, x: number) {
|
||||
let angle = Math.atan2(y, x);
|
||||
if (angle < 0) {
|
||||
angle += 2 * Math.PI;
|
||||
}
|
||||
|
||||
return (angle * 180) / Math.PI;
|
||||
}
|
||||
|
||||
private toNormalizedAngle(angle: number) {
|
||||
if (angle < 0) {
|
||||
angle += 360;
|
||||
}
|
||||
|
||||
return Math.round(angle) % 360;
|
||||
}
|
||||
|
||||
private rotateSingle(option: {
|
||||
orig: ReadonlyIBound;
|
||||
model: GfxModel;
|
||||
startPt: IVec;
|
||||
currentPt: IVec;
|
||||
snap: boolean;
|
||||
onRotateUpdate?: RotateOption['onRotateUpdate'];
|
||||
}) {
|
||||
const { orig, model, startPt, currentPt, snap, onRotateUpdate } = option;
|
||||
|
||||
const center = {
|
||||
x: orig.x + orig.w / 2,
|
||||
y: orig.y + orig.h / 2,
|
||||
};
|
||||
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
|
||||
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
|
||||
|
||||
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
|
||||
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
|
||||
|
||||
const startAngle = this.getNormalizedAngle(v0.y, v0.x),
|
||||
endAngle = this.getNormalizedAngle(v1.y, v1.x);
|
||||
const deltaDeg = endAngle - startAngle;
|
||||
const rotatedAngle = orig.rotate + deltaDeg;
|
||||
const targetRotate = this.toNormalizedAngle(
|
||||
snap
|
||||
? Math.round((rotatedAngle % 15) / 15) * 15 +
|
||||
Math.floor(rotatedAngle / 15) * 15
|
||||
: rotatedAngle
|
||||
);
|
||||
|
||||
// only used to provide the matrix information
|
||||
const rotateM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(targetRotate - orig.rotate)
|
||||
.translate(-center.x, -center.y);
|
||||
|
||||
onRotateUpdate?.({
|
||||
delta: deltaDeg,
|
||||
data: [
|
||||
{
|
||||
model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
originalRotate: orig.rotate,
|
||||
newRotate: targetRotate,
|
||||
matrix: rotateM,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private rotateMulti(option: {
|
||||
origs: ReadonlyIBound[];
|
||||
models: GfxModel[];
|
||||
startPt: IVec;
|
||||
currentPt: IVec;
|
||||
snap: boolean;
|
||||
onRotateUpdate?: RotateOption['onRotateUpdate'];
|
||||
}) {
|
||||
const { models, startPt, currentPt, onRotateUpdate } = option;
|
||||
const commonBound = getCommonBoundWithRotation(option.origs);
|
||||
|
||||
const center = {
|
||||
x: commonBound.x + commonBound.w / 2,
|
||||
y: commonBound.y + commonBound.h / 2,
|
||||
};
|
||||
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
|
||||
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
|
||||
|
||||
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
|
||||
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
|
||||
const a0 = this.getNormalizedAngle(v0.y, v0.x),
|
||||
a1 = this.getNormalizedAngle(v1.y, v1.x);
|
||||
const deltaDeg = a1 - a0;
|
||||
const rotateM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(deltaDeg)
|
||||
.translate(-center.x, -center.y);
|
||||
const toRotatedPoint = (p: DOMPoint) => p.matrixTransform(rotateM);
|
||||
|
||||
onRotateUpdate?.({
|
||||
delta: deltaDeg,
|
||||
data: models.map((model, index) => {
|
||||
const orig = option.origs[index];
|
||||
const center = {
|
||||
x: orig.x + orig.w / 2,
|
||||
y: orig.y + orig.h / 2,
|
||||
};
|
||||
|
||||
const visualM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(orig.rotate)
|
||||
.translate(-center.x, -center.y);
|
||||
const toVisual = (p: DOMPoint) => p.matrixTransform(visualM);
|
||||
|
||||
const [rotatedVisualLeftTop, rotatedVisualBottomRight] = [
|
||||
new DOMPoint(orig.x, orig.y),
|
||||
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
|
||||
].map(p => toRotatedPoint(toVisual(p)));
|
||||
|
||||
const newCenter = {
|
||||
x:
|
||||
Math.min(rotatedVisualLeftTop.x, rotatedVisualBottomRight.x) +
|
||||
Math.abs(rotatedVisualBottomRight.x - rotatedVisualLeftTop.x) / 2,
|
||||
y:
|
||||
Math.min(rotatedVisualLeftTop.y, rotatedVisualBottomRight.y) +
|
||||
Math.abs(rotatedVisualBottomRight.y - rotatedVisualLeftTop.y) / 2,
|
||||
};
|
||||
const newRotated = this.toNormalizedAngle(orig.rotate + deltaDeg);
|
||||
const finalM = new DOMMatrix()
|
||||
.translate(newCenter.x, newCenter.y)
|
||||
.rotate(-newRotated)
|
||||
.translate(-newCenter.x, -newCenter.y)
|
||||
.multiply(rotateM)
|
||||
.multiply(visualM);
|
||||
|
||||
const topLeft = rotatedVisualLeftTop.matrixTransform(
|
||||
new DOMMatrix()
|
||||
.translate(newCenter.x, newCenter.y)
|
||||
.rotate(-newRotated)
|
||||
.translate(-newCenter.x, -newCenter.y)
|
||||
);
|
||||
|
||||
return {
|
||||
model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound: new Bound(topLeft.x, topLeft.y, orig.w, orig.h),
|
||||
originalRotate: orig.rotate,
|
||||
newRotate: newRotated,
|
||||
matrix: finalM,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private getHandleSign(handle: ResizeHandle) {
|
||||
switch (handle) {
|
||||
case 'top-left':
|
||||
return { x: -1, y: -1 };
|
||||
case 'top':
|
||||
return { x: 0, y: -1 };
|
||||
case 'top-right':
|
||||
return { x: 1, y: -1 };
|
||||
case 'right':
|
||||
return { x: 1, y: 0 };
|
||||
case 'bottom-right':
|
||||
return { x: 1, y: 1 };
|
||||
case 'bottom':
|
||||
return { x: 0, y: 1 };
|
||||
case 'bottom-left':
|
||||
return { x: -1, y: 1 };
|
||||
case 'left':
|
||||
return { x: -1, y: 0 };
|
||||
default:
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { GfxModel } from '../../model/model';
|
||||
|
||||
export type ExtensionElementsCloneContext = {
|
||||
elements: GfxModel[];
|
||||
};
|
||||
|
||||
export type RequestElementsCloneContext = ExtensionElementsCloneContext;
|
||||
73
blocksuite/framework/std/src/gfx/interactivity/types/drag.ts
Normal file
73
blocksuite/framework/std/src/gfx/interactivity/types/drag.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import type { GfxBlockComponent } from '../../../view';
|
||||
import type { GfxModel } from '../../model/model';
|
||||
import type { GfxElementModelView } from '../../view/view';
|
||||
|
||||
export type DragInitializationOption = {
|
||||
movingElements: GfxModel[];
|
||||
event: PointerEvent | MouseEvent;
|
||||
onDragEnd?: () => void;
|
||||
};
|
||||
|
||||
export type DragExtensionInitializeContext = {
|
||||
/**
|
||||
* The elements that are being dragged.
|
||||
* The extension can modify this array to add or remove dragging elements.
|
||||
*/
|
||||
elements: GfxModel[];
|
||||
|
||||
/**
|
||||
* Prevent the default drag behavior. The following drag events will not be triggered.
|
||||
*/
|
||||
preventDefault: () => void;
|
||||
|
||||
/**
|
||||
* The start position of the drag in model space.
|
||||
*/
|
||||
dragStartPos: Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ExtensionBaseEvent = {
|
||||
/**
|
||||
* The elements that respond to the event.
|
||||
*/
|
||||
elements: {
|
||||
view: GfxBlockComponent | GfxElementModelView;
|
||||
originalBound: Bound;
|
||||
model: GfxModel;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* The mouse event
|
||||
*/
|
||||
event: PointerEvent;
|
||||
|
||||
/**
|
||||
* The start position of the drag in model space.
|
||||
*/
|
||||
dragStartPos: Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The last position of the drag in model space.
|
||||
*/
|
||||
dragLastPos: Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ExtensionDragStartContext = ExtensionBaseEvent;
|
||||
|
||||
export type ExtensionDragMoveContext = ExtensionBaseEvent & {
|
||||
dx: number;
|
||||
dy: number;
|
||||
};
|
||||
|
||||
export type ExtensionDragEndContext = ExtensionDragMoveContext;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IBound, IPoint, IVec } from '@blocksuite/global/gfx';
|
||||
|
||||
import type { GfxModel } from '../../model/model';
|
||||
import type { ResizeHandle } from '../resize/manager';
|
||||
|
||||
export type ExtensionElementResizeContext = {
|
||||
elements: GfxModel[];
|
||||
};
|
||||
|
||||
export type ExtensionElementResizeStartContext = {
|
||||
elements: GfxModel[];
|
||||
|
||||
/**
|
||||
* The position of the handle in the browser coordinate space.
|
||||
*/
|
||||
handlePos: IVec;
|
||||
|
||||
/**
|
||||
* The sign (or normal vector) of the handle.
|
||||
*/
|
||||
handleSign: IPoint;
|
||||
|
||||
handle: ResizeHandle;
|
||||
};
|
||||
|
||||
export type ExtensionElementResizeEndContext =
|
||||
ExtensionElementResizeStartContext;
|
||||
|
||||
export type ExtensionElementResizeMoveContext =
|
||||
ExtensionElementResizeStartContext & {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
|
||||
originalBound: IBound;
|
||||
|
||||
currentHandlePos: IVec;
|
||||
|
||||
lockRatio: boolean;
|
||||
|
||||
suggest: (distance: { scaleX: number; scaleY: number }) => void;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { GfxModel } from '../../model/model';
|
||||
|
||||
export type ExtensionElementSelectContext = {
|
||||
/**
|
||||
* The candidate elements for selection.
|
||||
*/
|
||||
candidates: GfxModel[];
|
||||
|
||||
/**
|
||||
* The element which is ready to be selected.
|
||||
*/
|
||||
target: GfxModel;
|
||||
|
||||
/**
|
||||
* Use to change the target element of selection.
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
suggest: (element: {
|
||||
/**
|
||||
* The suggested element id
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The priority of the suggestion. If there are multiple suggestions coming from different extensions,
|
||||
* the one with the highest priority will be used.
|
||||
*
|
||||
* Default to 0.
|
||||
*/
|
||||
priority?: number;
|
||||
}) => void;
|
||||
};
|
||||
173
blocksuite/framework/std/src/gfx/interactivity/types/view.ts
Normal file
173
blocksuite/framework/std/src/gfx/interactivity/types/view.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Bound, IBound, IPoint } from '@blocksuite/global/gfx';
|
||||
|
||||
import type { GfxBlockComponent } from '../../../view/element/gfx-block-component.js';
|
||||
import type { GfxModel } from '../../model/model.js';
|
||||
import type { GfxElementModelView } from '../../view/view.js';
|
||||
import type { ResizeHandle } from '../resize/manager.js';
|
||||
|
||||
export type DragStartContext = {
|
||||
/**
|
||||
* The elements that are being dragged
|
||||
*/
|
||||
elements: {
|
||||
view: GfxBlockComponent | GfxElementModelView;
|
||||
originalBound: Bound;
|
||||
model: GfxModel;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* The bound of element when drag starts
|
||||
*/
|
||||
currentBound: Bound;
|
||||
};
|
||||
|
||||
export type DragMoveContext = DragStartContext & {
|
||||
/**
|
||||
* The delta x of current drag position compared to the start position in model coordinate.
|
||||
*/
|
||||
dx: number;
|
||||
|
||||
/**
|
||||
* The delta y of current drag position compared to the start position in model coordinate.
|
||||
*/
|
||||
dy: number;
|
||||
};
|
||||
|
||||
export type DragEndContext = DragMoveContext;
|
||||
|
||||
export type ResizeConstraint = {
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
allowedHandlers?: ResizeHandle[];
|
||||
|
||||
/**
|
||||
* Whether to lock the aspect ratio of the element when resizing.
|
||||
* If the value is an array, it will only lock the aspect ratio when resizing the specified handles.
|
||||
*/
|
||||
lockRatio?: boolean | ResizeHandle[];
|
||||
};
|
||||
|
||||
export type BeforeResizeContext = {
|
||||
/**
|
||||
* The elements that will be resized
|
||||
*/
|
||||
elements: (GfxBlockComponent | GfxElementModelView)[];
|
||||
|
||||
/**
|
||||
* Set the constraint before resize starts.
|
||||
*/
|
||||
set: (constraint: ResizeConstraint) => void;
|
||||
};
|
||||
|
||||
export type ResizeStartContext = {
|
||||
/**
|
||||
* The handle that is used to resize the element
|
||||
*/
|
||||
handle: ResizeHandle;
|
||||
|
||||
/**
|
||||
* The resize constraint.
|
||||
*/
|
||||
constraint: Readonly<Required<ResizeConstraint>>;
|
||||
};
|
||||
|
||||
export type ResizeMoveContext = ResizeStartContext & {
|
||||
/**
|
||||
* The element bound when resize starts
|
||||
*/
|
||||
originalBound: Bound;
|
||||
|
||||
newBound: Bound;
|
||||
|
||||
/**
|
||||
* The matrix that used to transform the element.
|
||||
*/
|
||||
matrix: DOMMatrix;
|
||||
|
||||
lockRatio: boolean;
|
||||
};
|
||||
|
||||
export type ResizeEndContext = ResizeStartContext;
|
||||
|
||||
export type RotateConstraint = {
|
||||
rotatable?: boolean;
|
||||
};
|
||||
|
||||
export type BeforeRotateContext = {
|
||||
/**
|
||||
* The elements that will be rotated
|
||||
*/
|
||||
elements: (GfxBlockComponent | GfxElementModelView)[];
|
||||
|
||||
/**
|
||||
* Set the constraint before rotate starts.
|
||||
*/
|
||||
set: (constraint: RotateConstraint) => void;
|
||||
};
|
||||
|
||||
export type RotateStartContext = {
|
||||
constraint: Readonly<Required<RotateConstraint>>;
|
||||
};
|
||||
|
||||
export type RotateMoveContext = RotateStartContext & {
|
||||
newBound: Bound;
|
||||
|
||||
originalBound: Bound;
|
||||
|
||||
newRotate: number;
|
||||
|
||||
originalRotate: number;
|
||||
|
||||
matrix: DOMMatrix;
|
||||
};
|
||||
|
||||
export type RotateEndContext = RotateStartContext;
|
||||
|
||||
export type SelectableContext = {
|
||||
/**
|
||||
* Whether is multi-select, usually triggered by shift key
|
||||
*/
|
||||
multiSelect: boolean;
|
||||
|
||||
/**
|
||||
* The pointer event that triggers the selection
|
||||
*/
|
||||
event: PointerEvent;
|
||||
|
||||
/**
|
||||
* The model position of the event pointer
|
||||
*/
|
||||
position: IPoint;
|
||||
};
|
||||
|
||||
export type SelectContext = SelectableContext & {
|
||||
/**
|
||||
* The selected state of the element
|
||||
*/
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type BoxSelectionContext = {
|
||||
box: Readonly<
|
||||
IBound & {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type GfxViewTransformInterface = {
|
||||
onDragStart: (context: DragStartContext) => void;
|
||||
onDragMove: (context: DragMoveContext) => void;
|
||||
onDragEnd: (context: DragEndContext) => void;
|
||||
|
||||
/**
|
||||
* When the element is selected by box selection, return false to prevent the default selection behavior.
|
||||
*/
|
||||
onBoxSelected: (context: BoxSelectionContext) => boolean | void;
|
||||
};
|
||||
51
blocksuite/framework/std/src/gfx/keyboard.ts
Normal file
51
blocksuite/framework/std/src/gfx/keyboard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
|
||||
export class KeyboardController {
|
||||
private readonly _disposable = new DisposableGroup();
|
||||
|
||||
shiftKey$ = new Signal<boolean>(false);
|
||||
|
||||
spaceKey$ = new Signal<boolean>(false);
|
||||
|
||||
constructor(readonly std: BlockStdScope) {
|
||||
this._init();
|
||||
}
|
||||
|
||||
private _init() {
|
||||
this._disposable.add(
|
||||
this._listenKeyboard('keydown', evt => {
|
||||
this.shiftKey$.value = evt.shiftKey && evt.key === 'Shift';
|
||||
this.spaceKey$.value = evt.code === 'Space';
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
this._listenKeyboard('keyup', evt => {
|
||||
this.shiftKey$.value =
|
||||
evt.shiftKey && evt.key === 'Shift' ? true : false;
|
||||
|
||||
if (evt.code === 'Space') {
|
||||
this.spaceKey$.value = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _listenKeyboard(
|
||||
event: 'keydown' | 'keyup',
|
||||
callback: (keyboardEvt: KeyboardEvent) => void
|
||||
) {
|
||||
document.addEventListener(event, callback, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(event, callback, false);
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._disposable.dispose();
|
||||
}
|
||||
}
|
||||
1014
blocksuite/framework/std/src/gfx/layer.ts
Normal file
1014
blocksuite/framework/std/src/gfx/layer.ts
Normal file
File diff suppressed because it is too large
Load Diff
173
blocksuite/framework/std/src/gfx/model/base.ts
Normal file
173
blocksuite/framework/std/src/gfx/model/base.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type {
|
||||
Bound,
|
||||
IBound,
|
||||
IVec,
|
||||
PointLocation,
|
||||
SerializedXYWH,
|
||||
XYWH,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
import type { EditorHost } from '../../view/element/lit-host.js';
|
||||
import type { GfxGroupModel, GfxModel } from './model.js';
|
||||
|
||||
/**
|
||||
* The methods that a graphic element should implement.
|
||||
* It is already included in the `GfxCompatibleInterface` interface.
|
||||
*/
|
||||
export interface GfxElementGeometry {
|
||||
containsBound(bound: Bound): boolean;
|
||||
getNearestPoint(point: IVec): IVec;
|
||||
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null;
|
||||
getRelativePointLocation(point: IVec): PointLocation;
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions,
|
||||
host: EditorHost
|
||||
): boolean;
|
||||
intersectsBound(bound: Bound): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the model that can be rendered in graphics mode should implement this interface.
|
||||
*/
|
||||
export interface GfxCompatibleInterface extends IBound, GfxElementGeometry {
|
||||
xywh: SerializedXYWH;
|
||||
index: string;
|
||||
|
||||
/**
|
||||
* Defines the extension of the response area beyond the element's bounding box.
|
||||
* This tuple specifies the horizontal and vertical margins to be added to the element's bound.
|
||||
*
|
||||
* The first value represents the horizontal extension (added to both left and right sides),
|
||||
* and the second value represents the vertical extension (added to both top and bottom sides).
|
||||
*
|
||||
* The response area is computed as:
|
||||
* `[x - horizontal, y - vertical, w + 2 * horizontal, h + 2 * vertical]`.
|
||||
*
|
||||
* Example:
|
||||
* - xywh: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
|
||||
* Resulting response area: `[-10, -20, 120, 140]`.
|
||||
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
|
||||
*/
|
||||
responseExtension: [number, number];
|
||||
|
||||
readonly group: GfxGroupCompatibleInterface | null;
|
||||
|
||||
readonly groups: GfxGroupCompatibleInterface[];
|
||||
|
||||
readonly deserializedXYWH: XYWH;
|
||||
|
||||
/**
|
||||
* The bound of the element without considering the response extension.
|
||||
*/
|
||||
readonly elementBound: Bound;
|
||||
|
||||
/**
|
||||
* The bound of the element considering the response extension.
|
||||
*/
|
||||
readonly responseBound: Bound;
|
||||
|
||||
/**
|
||||
* Indicates whether the current block is explicitly locked by self.
|
||||
* For checking the lock status of the element, use `isLocked` instead.
|
||||
* For (un)locking the element, use `(un)lock` instead.
|
||||
*/
|
||||
lockedBySelf?: boolean;
|
||||
|
||||
/**
|
||||
* Check if the element is locked. It will check the lock status of the element and its ancestors.
|
||||
*/
|
||||
isLocked(): boolean;
|
||||
isLockedBySelf(): boolean;
|
||||
isLockedByAncestor(): boolean;
|
||||
|
||||
lock(): void;
|
||||
unlock(): void;
|
||||
|
||||
/**
|
||||
* Whether to disable fallback rendering for this element, e.g., during zooming.
|
||||
* Defaults to false (fallback to placeholder rendering is enabled).
|
||||
*/
|
||||
forceFullRender?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The symbol to mark a model as a container.
|
||||
*/
|
||||
export const gfxGroupCompatibleSymbol = Symbol('GfxGroupCompatible');
|
||||
|
||||
/**
|
||||
* Check if the element is a container element.
|
||||
*/
|
||||
export const isGfxGroupCompatibleModel = (
|
||||
elm: unknown
|
||||
): elm is GfxGroupModel => {
|
||||
if (typeof elm !== 'object' || elm === null) return false;
|
||||
return (
|
||||
gfxGroupCompatibleSymbol in elm && elm[gfxGroupCompatibleSymbol] === true
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* GfxGroupCompatibleElement is a model that can contain other models.
|
||||
* It just like a group that in common graphic software.
|
||||
*/
|
||||
export interface GfxGroupCompatibleInterface extends GfxCompatibleInterface {
|
||||
[gfxGroupCompatibleSymbol]: true;
|
||||
|
||||
/**
|
||||
* All child ids of this container.
|
||||
*/
|
||||
childIds: string[];
|
||||
|
||||
/**
|
||||
* All child element models of this container.
|
||||
* Note that the `childElements` may not contains all the children in `childIds`,
|
||||
* because some children may not be loaded.
|
||||
*/
|
||||
childElements: GfxModel[];
|
||||
|
||||
descendantElements: GfxModel[];
|
||||
|
||||
addChild(element: GfxCompatibleInterface): void;
|
||||
removeChild(element: GfxCompatibleInterface): void;
|
||||
hasChild(element: GfxCompatibleInterface): boolean;
|
||||
|
||||
hasDescendant(element: GfxCompatibleInterface): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The options for the hit testing of a point.
|
||||
*/
|
||||
export interface PointTestOptions {
|
||||
/**
|
||||
* The threshold of the hit test. The unit is pixel.
|
||||
*/
|
||||
hitThreshold?: number;
|
||||
|
||||
/**
|
||||
* If true, the element bound will be used for the hit testing.
|
||||
* By default, the response bound will be used.
|
||||
*/
|
||||
useElementBound?: boolean;
|
||||
|
||||
/**
|
||||
* The padding of the response area for each element when do the hit testing. The unit is pixel.
|
||||
* The first value is the padding for the x-axis, and the second value is the padding for the y-axis.
|
||||
*/
|
||||
responsePadding?: [number, number];
|
||||
|
||||
/**
|
||||
* If true, the transparent area of the element will be ignored during the point inclusion test.
|
||||
* Otherwise, the transparent area will be considered as filled area.
|
||||
*
|
||||
* Default is true.
|
||||
*/
|
||||
ignoreTransparent?: boolean;
|
||||
|
||||
/**
|
||||
* The zoom level of current view when do the hit testing.
|
||||
*/
|
||||
zoom?: number;
|
||||
}
|
||||
315
blocksuite/framework/std/src/gfx/model/gfx-block-model.ts
Normal file
315
blocksuite/framework/std/src/gfx/model/gfx-block-model.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getBoundWithRotation,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
isLockedByAncestorImpl,
|
||||
isLockedBySelfImpl,
|
||||
isLockedImpl,
|
||||
lockElementImpl,
|
||||
unlockElementImpl,
|
||||
} from '../../utils/tree.js';
|
||||
import type { EditorHost } from '../../view/index.js';
|
||||
import type { GfxCompatibleInterface, PointTestOptions } from './base.js';
|
||||
import type { GfxGroupModel } from './model.js';
|
||||
import type { SurfaceBlockModel } from './surface/surface-model.js';
|
||||
|
||||
/**
|
||||
* The props that a graphics block model should have.
|
||||
*/
|
||||
export type GfxCompatibleProps = {
|
||||
xywh: SerializedXYWH;
|
||||
index: string;
|
||||
lockedBySelf?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* This type include the common props for the graphic block model.
|
||||
* You can use this type with Omit to define the props of a graphic block model.
|
||||
*/
|
||||
export type GfxCommonBlockProps = GfxCompatibleProps & {
|
||||
rotate: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The graphic block model that can be rendered in the graphics mode.
|
||||
* All the graphic block model should extend this class.
|
||||
* You can use `GfxCompatibleBlockModel` to convert a BlockModel to a subclass that extends it.
|
||||
*/
|
||||
export class GfxBlockElementModel<
|
||||
Props extends GfxCompatibleProps = GfxCompatibleProps,
|
||||
>
|
||||
extends BlockModel<Props>
|
||||
implements GfxCompatibleInterface
|
||||
{
|
||||
private _cacheDeserKey: string | null = null;
|
||||
|
||||
private _cacheDeserXYWH: XYWH | null = null;
|
||||
|
||||
private _externalXYWH: SerializedXYWH | undefined = undefined;
|
||||
|
||||
connectable = true;
|
||||
|
||||
get xywh() {
|
||||
return this.props.xywh;
|
||||
}
|
||||
|
||||
get xywh$() {
|
||||
return this.props.xywh$;
|
||||
}
|
||||
|
||||
set xywh(xywh: SerializedXYWH) {
|
||||
this.props.xywh = xywh;
|
||||
}
|
||||
|
||||
get index() {
|
||||
return this.props.index;
|
||||
}
|
||||
|
||||
get index$() {
|
||||
return this.props.index$;
|
||||
}
|
||||
|
||||
set index(index: string) {
|
||||
this.props.index = index;
|
||||
}
|
||||
|
||||
get lockedBySelf(): boolean | undefined {
|
||||
return this.props.lockedBySelf;
|
||||
}
|
||||
|
||||
get lockedBySelf$() {
|
||||
return this.props.lockedBySelf$;
|
||||
}
|
||||
|
||||
set lockedBySelf(lockedBySelf: boolean | undefined) {
|
||||
this.props.lockedBySelf = lockedBySelf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the extension of the response area beyond the element's bounding box.
|
||||
* This tuple specifies the horizontal and vertical margins to be added to the element's [x, y, width, height].
|
||||
*
|
||||
* The first value represents the horizontal extension (added to both left and right sides),
|
||||
* and the second value represents the vertical extension (added to both top and bottom sides).
|
||||
*
|
||||
* The response area is computed as:
|
||||
* `[x - horizontal, y - vertical, width + 2 * horizontal, height + 2 * vertical]`.
|
||||
*
|
||||
* Example:
|
||||
* - Bounding box: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
|
||||
* Resulting response area: `[-10, -20, 120, 140]`.
|
||||
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
|
||||
*/
|
||||
responseExtension: [number, number] = [0, 0];
|
||||
|
||||
get rotate() {
|
||||
if ('rotate' in this.props) {
|
||||
return this.props.rotate as number;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
set rotate(rotate: number) {
|
||||
if ('rotate' in this.props) {
|
||||
this.props.rotate = rotate;
|
||||
}
|
||||
}
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) {
|
||||
this._cacheDeserKey = this.xywh;
|
||||
this._cacheDeserXYWH = deserializeXYWH(this.xywh);
|
||||
}
|
||||
|
||||
return this._cacheDeserXYWH;
|
||||
}
|
||||
|
||||
get elementBound() {
|
||||
return Bound.from(getBoundWithRotation(this));
|
||||
}
|
||||
|
||||
get externalBound(): Bound | null {
|
||||
return this._externalXYWH ? Bound.deserialize(this._externalXYWH) : null;
|
||||
}
|
||||
|
||||
get externalXYWH(): SerializedXYWH | undefined {
|
||||
return this._externalXYWH;
|
||||
}
|
||||
|
||||
set externalXYWH(xywh: SerializedXYWH | undefined) {
|
||||
this._externalXYWH = xywh;
|
||||
}
|
||||
|
||||
get group(): GfxGroupModel | null {
|
||||
if (!this.surface) return null;
|
||||
|
||||
return this.surface.getGroup(this.id) ?? null;
|
||||
}
|
||||
|
||||
get groups(): GfxGroupModel[] {
|
||||
if (!this.surface) return [];
|
||||
|
||||
return this.surface.getGroups(this.id);
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
get surface(): SurfaceBlockModel | null {
|
||||
const result = this.store.getBlocksByFlavour('affine:surface');
|
||||
if (result.length === 0) return null;
|
||||
return result[0].model as SurfaceBlockModel;
|
||||
}
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const points = getPointsFromBoundWithRotation({
|
||||
x: bound.x,
|
||||
y: bound.y,
|
||||
w: bound.w,
|
||||
h: bound.h,
|
||||
rotate: this.rotate,
|
||||
});
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
|
||||
return linePolygonIntersects(
|
||||
start,
|
||||
end,
|
||||
rotatePoints(bound.points, bound.center, this.rotate ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec): IVec {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
return polygonNearestPoint(
|
||||
rotatePoints(bound.points, bound.center, this.rotate ?? 0),
|
||||
point
|
||||
);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec): PointLocation {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints(
|
||||
[point],
|
||||
bound.center,
|
||||
this.rotate ?? 0
|
||||
)[0];
|
||||
const points = rotatePoints(bound.points, bound.center, this.rotate ?? 0);
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
opt: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
|
||||
return bound.isPointInBound([x, y], 0);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
isLocked(): boolean {
|
||||
return isLockedImpl(this);
|
||||
}
|
||||
|
||||
isLockedByAncestor(): boolean {
|
||||
return isLockedByAncestorImpl(this);
|
||||
}
|
||||
|
||||
isLockedBySelf(): boolean {
|
||||
return isLockedBySelfImpl(this);
|
||||
}
|
||||
|
||||
lock() {
|
||||
lockElementImpl(this.store, this);
|
||||
}
|
||||
|
||||
unlock() {
|
||||
unlockElementImpl(this.store, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a BlockModel to a GfxBlockElementModel.
|
||||
* @param BlockModelSuperClass The BlockModel class to be converted.
|
||||
* @returns The returned class is a subclass of the GfxBlockElementModel class and the given BlockModelSuperClass.
|
||||
*/
|
||||
export function GfxCompatibleBlockModel<
|
||||
Props extends GfxCompatibleProps,
|
||||
T extends Constructor<BlockModel<Props>> = Constructor<BlockModel<Props>>,
|
||||
>(BlockModelSuperClass: T) {
|
||||
if (BlockModelSuperClass === BlockModel) {
|
||||
return GfxBlockElementModel as unknown as typeof GfxBlockElementModel<Props>;
|
||||
} else {
|
||||
let currentClass = BlockModelSuperClass;
|
||||
|
||||
while (
|
||||
Object.getPrototypeOf(currentClass.prototype) !== BlockModel.prototype &&
|
||||
Object.getPrototypeOf(currentClass.prototype) !== null
|
||||
) {
|
||||
currentClass = Object.getPrototypeOf(currentClass.prototype).constructor;
|
||||
}
|
||||
|
||||
if (Object.getPrototypeOf(currentClass.prototype) === null) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'The SuperClass is not a subclass of BlockModel'
|
||||
);
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(
|
||||
currentClass.prototype,
|
||||
GfxBlockElementModel.prototype
|
||||
);
|
||||
}
|
||||
|
||||
return BlockModelSuperClass as unknown as typeof GfxBlockElementModel<Props>;
|
||||
}
|
||||
18
blocksuite/framework/std/src/gfx/model/model.ts
Normal file
18
blocksuite/framework/std/src/gfx/model/model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { GfxGroupCompatibleInterface } from './base.js';
|
||||
import type { GfxBlockElementModel } from './gfx-block-model.js';
|
||||
import {
|
||||
type GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from './surface/element-model.js';
|
||||
|
||||
export type GfxModel = GfxBlockElementModel | GfxPrimitiveElementModel;
|
||||
|
||||
export type GfxGroupModel =
|
||||
| (GfxGroupCompatibleInterface & GfxBlockElementModel)
|
||||
| GfxGroupLikeElementModel;
|
||||
|
||||
export const isPrimitiveModel = (
|
||||
model: GfxModel
|
||||
): model is GfxPrimitiveElementModel => {
|
||||
return model instanceof GfxPrimitiveElementModel;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SurfaceBlockModel } from '../surface-model.js';
|
||||
|
||||
/**
|
||||
* Set metadata for a property
|
||||
* @param symbol Unique symbol for the metadata
|
||||
* @param target The target object to set metadata on, usually the prototype
|
||||
* @param prop The property name
|
||||
* @param val The value to set
|
||||
*/
|
||||
export function setObjectPropMeta(
|
||||
symbol: symbol,
|
||||
target: unknown,
|
||||
prop: string | symbol,
|
||||
val: unknown
|
||||
) {
|
||||
// @ts-expect-error ignore
|
||||
target[symbol] = target[symbol] ?? {};
|
||||
// @ts-expect-error ignore
|
||||
target[symbol][prop] = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a property
|
||||
* @param target The target object to retrieve metadata from, usually the prototype
|
||||
* @param symbol Unique symbol for the metadata
|
||||
* @param prop The property name, if not provided, returns all metadata for that symbol
|
||||
* @returns
|
||||
*/
|
||||
export function getObjectPropMeta(
|
||||
target: unknown,
|
||||
symbol: symbol,
|
||||
prop?: string | symbol
|
||||
) {
|
||||
if (prop) {
|
||||
// @ts-expect-error ignore
|
||||
return target[symbol]?.[prop] ?? null;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore
|
||||
return target[symbol] ?? {};
|
||||
}
|
||||
|
||||
export function getDecoratorState(surface: SurfaceBlockModel) {
|
||||
return surface['_decoratorState'];
|
||||
}
|
||||
|
||||
export function createDecoratorState() {
|
||||
return {
|
||||
creating: false,
|
||||
deriving: false,
|
||||
skipField: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
const convertSymbol = Symbol('convert');
|
||||
|
||||
/**
|
||||
* The convert decorator is used to convert the property value before it's
|
||||
* set to the Y map.
|
||||
*
|
||||
* Note:
|
||||
* 1. This decorator function will not execute in model initialization.
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function convert<V, T extends GfxPrimitiveElementModel>(
|
||||
fn: (propValue: V, instance: T) => unknown
|
||||
) {
|
||||
return function convertDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = String(context.name);
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
const proto = Object.getPrototypeOf(this);
|
||||
setObjectPropMeta(convertSymbol, proto, prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getConvertMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
): null | ((propValue: unknown, instance: unknown) => unknown) {
|
||||
return getObjectPropMeta(proto, convertSymbol, prop);
|
||||
}
|
||||
|
||||
export function convertProps(
|
||||
propName: string | symbol,
|
||||
propValue: unknown,
|
||||
receiver: unknown
|
||||
) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const convertFn = getConvertMeta(proto, propName as string)!;
|
||||
|
||||
return convertFn ? convertFn(propValue, receiver) : propValue;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import {
|
||||
getDecoratorState,
|
||||
getObjectPropMeta,
|
||||
setObjectPropMeta,
|
||||
} from './common.js';
|
||||
|
||||
const deriveSymbol = Symbol('derive');
|
||||
|
||||
const keys = Object.keys;
|
||||
|
||||
function getDerivedMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
):
|
||||
| null
|
||||
| ((propValue: unknown, instance: unknown) => Record<string, unknown>)[] {
|
||||
return getObjectPropMeta(proto, deriveSymbol, prop);
|
||||
}
|
||||
|
||||
export function getDerivedProps(
|
||||
prop: string | symbol,
|
||||
propValue: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const prototype = Object.getPrototypeOf(receiver);
|
||||
const decoratorState = getDecoratorState(receiver.surface);
|
||||
|
||||
if (decoratorState.deriving || decoratorState.creating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deriveFns = getDerivedMeta(prototype, prop as string)!;
|
||||
|
||||
return deriveFns
|
||||
? deriveFns.reduce(
|
||||
(derivedProps, fn) => {
|
||||
const props = fn(propValue, receiver);
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
derivedProps[key] = value;
|
||||
});
|
||||
|
||||
return derivedProps;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function updateDerivedProps(
|
||||
derivedProps: Record<string, unknown> | null,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
if (derivedProps) {
|
||||
const decoratorState = getDecoratorState(receiver.surface);
|
||||
decoratorState.deriving = true;
|
||||
keys(derivedProps).forEach(key => {
|
||||
// @ts-expect-error ignore
|
||||
receiver[key] = derivedProps[key];
|
||||
});
|
||||
decoratorState.deriving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The derive decorator is used to derive other properties' update when the
|
||||
* decorated property is updated through assignment in the local.
|
||||
*
|
||||
* Note:
|
||||
* 1. The first argument of the function is the new value of the decorated property
|
||||
* before the `convert` decorator is called.
|
||||
* 2. The decorator function will execute after the decorated property has been updated.
|
||||
* 3. The decorator function will not execute during model creation.
|
||||
* 4. The decorator function will not execute if the decorated property is updated through
|
||||
* the Y map. That is to say, if other peers update the property will not trigger this decorator
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function derive<V, T extends GfxPrimitiveElementModel>(
|
||||
fn: (propValue: any, instance: T) => Record<string, unknown>
|
||||
) {
|
||||
return function deriveDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = String(context.name);
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
const proto = Object.getPrototypeOf(this);
|
||||
const derived = getDerivedMeta(proto, prop);
|
||||
|
||||
if (Array.isArray(derived)) {
|
||||
derived.push(fn as (typeof derived)[0]);
|
||||
} else {
|
||||
setObjectPropMeta(deriveSymbol, proto, prop as string, [fn]);
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getDecoratorState } from './common.js';
|
||||
import { convertProps } from './convert.js';
|
||||
import { getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
import { startObserve } from './observer.js';
|
||||
|
||||
const yPropsSetSymbol = Symbol('yProps');
|
||||
|
||||
export function getFieldPropsSet(target: unknown): Set<string | symbol> {
|
||||
const proto = Object.getPrototypeOf(target);
|
||||
if (!Object.hasOwn(proto, yPropsSetSymbol)) {
|
||||
proto[yPropsSetSymbol] = new Set();
|
||||
}
|
||||
|
||||
return proto[yPropsSetSymbol] as Set<string | symbol>;
|
||||
}
|
||||
|
||||
export function field<V, T extends GfxPrimitiveElementModel>(fallback?: V) {
|
||||
return function yDecorator(
|
||||
_: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
const yProps = getFieldPropsSet(this);
|
||||
|
||||
yProps.add(prop);
|
||||
|
||||
if (
|
||||
getDecoratorState(
|
||||
this.surface ?? Object.getPrototypeOf(this).constructor
|
||||
)?.skipField
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.yMap) {
|
||||
if (this.yMap.doc) {
|
||||
this.surface.store.transact(() => {
|
||||
this.yMap.set(prop as string, v);
|
||||
});
|
||||
} else {
|
||||
this.yMap.set(prop as string, v);
|
||||
this._preserved.set(prop as string, v);
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
get(this: GfxPrimitiveElementModel) {
|
||||
return (
|
||||
(this.yMap.doc ? this.yMap.get(prop as string) : null) ??
|
||||
this._preserved.get(prop as string) ??
|
||||
fallback
|
||||
);
|
||||
},
|
||||
set(this: T, originalVal: V) {
|
||||
const isCreating = getDecoratorState(this.surface)?.creating;
|
||||
|
||||
if (getDecoratorState(this.surface)?.skipField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const derivedProps = getDerivedProps(prop, originalVal, this);
|
||||
const val = isCreating
|
||||
? originalVal
|
||||
: convertProps(prop, originalVal, this);
|
||||
|
||||
if (this.yMap.doc) {
|
||||
this.surface.store.transact(() => {
|
||||
this.yMap.set(prop as string, val);
|
||||
});
|
||||
} else {
|
||||
this.yMap.set(prop as string, val);
|
||||
this._preserved.set(prop as string, val);
|
||||
}
|
||||
|
||||
startObserve(prop as string, this);
|
||||
|
||||
if (!isCreating) {
|
||||
updateDerivedProps(derivedProps, this);
|
||||
}
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { convert, convertProps } from './convert.js';
|
||||
export { derive, getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
export { field, getFieldPropsSet } from './field.js';
|
||||
export { local } from './local.js';
|
||||
export { initializeObservers, observe } from './observer.js';
|
||||
export { initializeWatchers, watch } from './watch.js';
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getDecoratorState } from './common.js';
|
||||
import { convertProps } from './convert.js';
|
||||
import { getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
|
||||
/**
|
||||
* A decorator to mark the property as a local property.
|
||||
*
|
||||
* The local property act like it is a field property, but it's not synced to the Y map.
|
||||
* Updating local property will also trigger the `elementUpdated` slot of the surface model
|
||||
*/
|
||||
export function local<V, T extends GfxPrimitiveElementModel>() {
|
||||
return function localDecorator(
|
||||
_target: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
this._local.set(prop, v);
|
||||
|
||||
return v;
|
||||
},
|
||||
get(this: T) {
|
||||
return this._local.get(prop);
|
||||
},
|
||||
set(this: T, originalValue: unknown) {
|
||||
const isCreating = getDecoratorState(this.surface)?.creating;
|
||||
const oldValue = this._local.get(prop);
|
||||
// When state is creating, the value is considered as default value
|
||||
// hence there's no need to convert it
|
||||
const newVal = isCreating
|
||||
? originalValue
|
||||
: convertProps(prop, originalValue, this);
|
||||
|
||||
const derivedProps = getDerivedProps(prop, originalValue, this);
|
||||
|
||||
this._local.set(prop, newVal);
|
||||
|
||||
// During creating, no need to invoke an update event and derive another update
|
||||
if (!isCreating) {
|
||||
updateDerivedProps(derivedProps, this);
|
||||
|
||||
this._onChange({
|
||||
props: {
|
||||
[prop]: newVal,
|
||||
},
|
||||
oldValues: {
|
||||
[prop]: oldValue,
|
||||
},
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
const observeSymbol = Symbol('observe');
|
||||
const observerDisposableSymbol = Symbol('observerDisposable');
|
||||
|
||||
type ObserveFn<
|
||||
E extends Y.YEvent<any> = Y.YEvent<any>,
|
||||
T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel,
|
||||
> = (
|
||||
/**
|
||||
* The event object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
|
||||
*/
|
||||
event: E | null,
|
||||
instance: T,
|
||||
/**
|
||||
* The transaction object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
|
||||
*/
|
||||
transaction: Y.Transaction | null
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* A decorator to observe the y type property.
|
||||
* You can think of it is just a decorator version of 'observe' method of Y.Array and Y.Map.
|
||||
*
|
||||
* The observer function start to observe the property when the model is mounted. And it will
|
||||
* re-observe the property automatically when the value is altered.
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function observe<
|
||||
V,
|
||||
E extends Y.YEvent<any>,
|
||||
T extends GfxPrimitiveElementModel,
|
||||
>(fn: ObserveFn<E, T>) {
|
||||
return function observeDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
setObjectPropMeta(observeSymbol, Object.getPrototypeOf(this), prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getObserveMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
): null | ObserveFn {
|
||||
return getObjectPropMeta(proto, observeSymbol, prop);
|
||||
}
|
||||
|
||||
export function startObserve(
|
||||
prop: string | symbol,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const observeFn = getObserveMeta(proto, prop as string)!;
|
||||
// @ts-expect-error ignore
|
||||
const observerDisposable = receiver[observerDisposableSymbol] ?? {};
|
||||
|
||||
// @ts-expect-error ignore
|
||||
receiver[observerDisposableSymbol] = observerDisposable;
|
||||
|
||||
if (observerDisposable[prop]) {
|
||||
observerDisposable[prop]();
|
||||
delete observerDisposable[prop];
|
||||
}
|
||||
|
||||
if (!observeFn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = receiver[prop as keyof GfxPrimitiveElementModel] as
|
||||
| Y.Map<unknown>
|
||||
| Y.Array<unknown>
|
||||
| null;
|
||||
|
||||
observeFn(null, receiver, null);
|
||||
|
||||
const fn = (event: Y.YEvent<any>, transaction: Y.Transaction) => {
|
||||
observeFn(event, receiver, transaction);
|
||||
};
|
||||
|
||||
if (value && 'observe' in value) {
|
||||
value.observe(fn);
|
||||
|
||||
observerDisposable[prop] = () => {
|
||||
value.unobserve(fn);
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`Failed to observe "${prop.toString()}" of ${
|
||||
receiver.type
|
||||
} element, make sure it's a Y type.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeObservers(
|
||||
proto: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const observers = getObjectPropMeta(proto, observeSymbol);
|
||||
|
||||
Object.keys(observers).forEach(prop => {
|
||||
startObserve(prop, receiver);
|
||||
});
|
||||
|
||||
receiver['_disposable'].add(() => {
|
||||
// @ts-expect-error ignore
|
||||
Object.values(receiver[observerDisposableSymbol] ?? {}).forEach(dispose =>
|
||||
(dispose as () => void)()
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
type WatchFn<T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel> = (
|
||||
oldValue: unknown,
|
||||
instance: T,
|
||||
local: boolean
|
||||
) => void;
|
||||
|
||||
const watchSymbol = Symbol('watch');
|
||||
|
||||
/**
|
||||
* The watch decorator is used to watch the property change of the element.
|
||||
* You can thinks of it as a decorator version of `elementUpdated` slot of the surface model.
|
||||
*/
|
||||
export function watch<V, T extends GfxPrimitiveElementModel>(fn: WatchFn<T>) {
|
||||
return function watchDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
setObjectPropMeta(watchSymbol, Object.getPrototypeOf(this), prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getWatchMeta(proto: unknown, prop: string | symbol): null | WatchFn {
|
||||
return getObjectPropMeta(proto, watchSymbol, prop);
|
||||
}
|
||||
|
||||
function startWatch(prop: string | symbol, receiver: GfxPrimitiveElementModel) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const watchFn = getWatchMeta(proto, prop as string)!;
|
||||
|
||||
if (!watchFn) return;
|
||||
|
||||
receiver['_disposable'].add(
|
||||
receiver.surface.elementUpdated.subscribe(payload => {
|
||||
if (payload.id === receiver.id && prop in payload.props) {
|
||||
watchFn(payload.oldValues[prop as string], receiver, payload.local);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function initializeWatchers(
|
||||
prototype: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const watchers = getObjectPropMeta(prototype, watchSymbol);
|
||||
|
||||
Object.keys(watchers).forEach(prop => {
|
||||
startWatch(prop, receiver);
|
||||
});
|
||||
}
|
||||
617
blocksuite/framework/std/src/gfx/model/surface/element-model.ts
Normal file
617
blocksuite/framework/std/src/gfx/model/surface/element-model.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getBoundWithRotation,
|
||||
getPointsFromBoundWithRotation,
|
||||
type IVec,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
randomSeed,
|
||||
rotatePoints,
|
||||
type SerializedXYWH,
|
||||
type XYWH,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { createMutex } from 'lib0/mutex';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
descendantElementsImpl,
|
||||
hasDescendantElementImpl,
|
||||
isLockedByAncestorImpl,
|
||||
isLockedBySelfImpl,
|
||||
isLockedImpl,
|
||||
lockElementImpl,
|
||||
unlockElementImpl,
|
||||
} from '../../../utils/tree.js';
|
||||
import type { EditorHost } from '../../../view/index.js';
|
||||
import type {
|
||||
GfxCompatibleInterface,
|
||||
GfxGroupCompatibleInterface,
|
||||
PointTestOptions,
|
||||
} from '../base.js';
|
||||
import { gfxGroupCompatibleSymbol } from '../base.js';
|
||||
import type { GfxBlockElementModel } from '../gfx-block-model.js';
|
||||
import type { GfxGroupModel, GfxModel } from '../model.js';
|
||||
import {
|
||||
convertProps,
|
||||
field,
|
||||
getDerivedProps,
|
||||
getFieldPropsSet,
|
||||
local,
|
||||
updateDerivedProps,
|
||||
watch,
|
||||
} from './decorators/index.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
|
||||
export type BaseElementProps = {
|
||||
index: string;
|
||||
seed: number;
|
||||
lockedBySelf?: boolean;
|
||||
comments?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type SerializedElement = Record<string, unknown> & {
|
||||
type: string;
|
||||
xywh: SerializedXYWH;
|
||||
id: string;
|
||||
index: string;
|
||||
lockedBySelf?: boolean;
|
||||
comments?: Record<string, boolean>;
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
export abstract class GfxPrimitiveElementModel<
|
||||
Props extends BaseElementProps = BaseElementProps,
|
||||
> implements GfxCompatibleInterface {
|
||||
private _lastXYWH!: SerializedXYWH;
|
||||
|
||||
protected _disposable = new DisposableGroup();
|
||||
|
||||
protected _id: string;
|
||||
|
||||
protected _local = new Map<string | symbol, unknown>();
|
||||
|
||||
protected _onChange: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* Used to store a copy of data in the yMap.
|
||||
*/
|
||||
protected _preserved = new Map<string, unknown>();
|
||||
|
||||
protected _stashed: Map<keyof Props | string, unknown>;
|
||||
|
||||
propsUpdated = new Subject<{ key: string }>();
|
||||
|
||||
abstract rotate: number;
|
||||
|
||||
surface!: SurfaceBlockModel;
|
||||
|
||||
abstract xywh: SerializedXYWH;
|
||||
|
||||
yMap: Y.Map<unknown>;
|
||||
|
||||
get connectable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get deserializedXYWH() {
|
||||
const xywh = this.xywh;
|
||||
|
||||
if (!this._lastXYWH || xywh !== this._lastXYWH) {
|
||||
this._local.set('deserializedXYWH', deserializeXYWH(xywh));
|
||||
this._lastXYWH = xywh;
|
||||
}
|
||||
|
||||
return (this._local.get('deserializedXYWH') as XYWH) ?? [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The bound of the element after rotation.
|
||||
* The bound without rotation should be created by `Bound.deserialize(this.xywh)`.
|
||||
*/
|
||||
get elementBound() {
|
||||
if (this.rotate) {
|
||||
return Bound.from(getBoundWithRotation(this));
|
||||
}
|
||||
|
||||
return Bound.deserialize(this.xywh);
|
||||
}
|
||||
|
||||
get externalBound(): Bound | null {
|
||||
if (!this._local.has('externalBound')) {
|
||||
const bound = this.externalXYWH
|
||||
? Bound.deserialize(this.externalXYWH)
|
||||
: null;
|
||||
|
||||
this._local.set('externalBound', bound);
|
||||
}
|
||||
|
||||
return this._local.get('externalBound') as Bound | null;
|
||||
}
|
||||
|
||||
get group(): GfxGroupModel | null {
|
||||
return this.surface.getGroup(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the ancestor elements in order from the most recent to the earliest.
|
||||
*/
|
||||
get groups(): GfxGroupModel[] {
|
||||
return this.surface.getGroups(this.id);
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.surface.hasElementById(this.id);
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
abstract get type(): string;
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
constructor(options: {
|
||||
id: string;
|
||||
yMap: Y.Map<unknown>;
|
||||
model: SurfaceBlockModel;
|
||||
stashedStore: Map<unknown, unknown>;
|
||||
onChange: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
}) {
|
||||
const { id, yMap, model, stashedStore, onChange } = options;
|
||||
|
||||
this._id = id;
|
||||
this.yMap = yMap;
|
||||
this.surface = model;
|
||||
this._stashed = stashedStore as Map<keyof Props, unknown>;
|
||||
this._onChange = onChange;
|
||||
|
||||
this.index = 'a0';
|
||||
this.seed = randomSeed();
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
return getPointsFromBoundWithRotation(this).some(point =>
|
||||
bounds.containsPoint(point)
|
||||
);
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return polygonNearestPoint(points, point);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec) {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0];
|
||||
const points = rotatePoints(bound.points, bound.center, this.rotate);
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
opt: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
|
||||
return bound.isPointInBound([x, y]);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
isLocked(): boolean {
|
||||
return isLockedImpl(this);
|
||||
}
|
||||
|
||||
isLockedByAncestor(): boolean {
|
||||
return isLockedByAncestorImpl(this);
|
||||
}
|
||||
|
||||
isLockedBySelf(): boolean {
|
||||
return isLockedBySelfImpl(this);
|
||||
}
|
||||
|
||||
lock() {
|
||||
lockElementImpl(this.surface.store, this);
|
||||
}
|
||||
|
||||
onCreated() {}
|
||||
|
||||
onDestroyed() {
|
||||
this._disposable.dispose();
|
||||
this.propsUpdated.complete();
|
||||
}
|
||||
|
||||
pop(prop: keyof Props | string) {
|
||||
if (!this._stashed.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this._stashed.get(prop);
|
||||
this._stashed.delete(prop);
|
||||
// @ts-expect-error ignore
|
||||
delete this[prop];
|
||||
|
||||
if (getFieldPropsSet(this).has(prop as string)) {
|
||||
if (!isEqual(value, this.yMap.get(prop as string))) {
|
||||
this.yMap.set(prop as string, value);
|
||||
}
|
||||
} else {
|
||||
console.warn('pop a prop that is not field or local:', prop);
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const result = this.yMap.toJSON();
|
||||
result.xywh = this.xywh;
|
||||
return result as SerializedElement;
|
||||
}
|
||||
|
||||
stash(prop: keyof Props | string) {
|
||||
if (this._stashed.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getFieldPropsSet(this).has(prop as string)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const curVal = this[prop as unknown as keyof GfxPrimitiveElementModel];
|
||||
|
||||
this._stashed.set(prop, curVal);
|
||||
|
||||
Object.defineProperty(this, prop, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => this._stashed.get(prop),
|
||||
set: (original: unknown) => {
|
||||
const value = convertProps(prop as string, original, this);
|
||||
const oldValue = this._stashed.get(prop);
|
||||
const derivedProps = getDerivedProps(
|
||||
prop as string,
|
||||
original,
|
||||
this as unknown as GfxPrimitiveElementModel
|
||||
);
|
||||
|
||||
this._stashed.set(prop, value);
|
||||
this._onChange({
|
||||
props: {
|
||||
[prop]: value,
|
||||
},
|
||||
oldValues: {
|
||||
[prop]: oldValue,
|
||||
},
|
||||
local: true,
|
||||
});
|
||||
|
||||
updateDerivedProps(
|
||||
derivedProps,
|
||||
this as unknown as GfxPrimitiveElementModel
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
unlock() {
|
||||
unlockElementImpl(this.surface.store, this);
|
||||
}
|
||||
|
||||
@local()
|
||||
accessor display: boolean = true;
|
||||
|
||||
/**
|
||||
* In some cases, you need to draw something related to the element, but it does not belong to the element itself.
|
||||
* And it is also interactive, you can select element by clicking on it. E.g. the title of the group element.
|
||||
* In this case, we need to store this kind of external xywh in order to do hit test. This property should not be synced to the doc.
|
||||
* This property should be updated every time it gets rendered.
|
||||
*/
|
||||
@watch((_, instance) => {
|
||||
instance['_local'].delete('externalBound');
|
||||
})
|
||||
@local()
|
||||
accessor externalXYWH: SerializedXYWH | undefined = undefined;
|
||||
|
||||
@field(false)
|
||||
accessor hidden: boolean = false;
|
||||
|
||||
@field()
|
||||
accessor index!: string;
|
||||
|
||||
@field()
|
||||
accessor lockedBySelf: boolean | undefined = false;
|
||||
|
||||
@local()
|
||||
accessor opacity: number = 1;
|
||||
|
||||
@local()
|
||||
accessor responseExtension: [number, number] = [0, 0];
|
||||
|
||||
@field()
|
||||
accessor seed!: number;
|
||||
|
||||
@field()
|
||||
accessor comments: Record<string, boolean> | undefined = undefined;
|
||||
}
|
||||
|
||||
export abstract class GfxGroupLikeElementModel<
|
||||
Props extends BaseElementProps = BaseElementProps,
|
||||
>
|
||||
extends GfxPrimitiveElementModel<Props>
|
||||
implements GfxGroupCompatibleInterface
|
||||
{
|
||||
private _childIds: string[] = [];
|
||||
|
||||
private _xywhDirty = true;
|
||||
|
||||
private readonly _mutex = createMutex();
|
||||
|
||||
abstract children: Y.Map<any>;
|
||||
|
||||
[gfxGroupCompatibleSymbol] = true as const;
|
||||
|
||||
get childElements() {
|
||||
const elements: GfxModel[] = [];
|
||||
|
||||
for (const key of this.childIds) {
|
||||
const element =
|
||||
this.surface.getElementById(key) ||
|
||||
(this.surface.store.getModelById(key) as GfxBlockElementModel);
|
||||
|
||||
element && elements.push(element);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of the children. Its role is to provide a unique way to access the children.
|
||||
* You should update this field through `setChildIds` when the children are added or removed.
|
||||
*/
|
||||
get childIds() {
|
||||
return this._childIds;
|
||||
}
|
||||
|
||||
get descendantElements(): GfxModel[] {
|
||||
return descendantElementsImpl(this);
|
||||
}
|
||||
|
||||
get xywh() {
|
||||
this._mutex(() => {
|
||||
if (this._xywhDirty || !this._local.has('xywh')) {
|
||||
this._local.set('xywh', this._getXYWH().serialize());
|
||||
this._xywhDirty = false;
|
||||
}
|
||||
});
|
||||
|
||||
return (this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
|
||||
}
|
||||
|
||||
set xywh(_) {}
|
||||
|
||||
protected _getXYWH(): Bound {
|
||||
let bound: Bound | undefined;
|
||||
|
||||
this.childElements.forEach(child => {
|
||||
if (child instanceof GfxPrimitiveElementModel && child.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
bound = bound ? bound.unite(child.elementBound) : child.elementBound;
|
||||
});
|
||||
|
||||
return bound ?? new Bound(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
invalidateXYWH() {
|
||||
this._xywhDirty = true;
|
||||
this._local.delete('deserializedXYWH');
|
||||
}
|
||||
|
||||
refreshXYWH(local: boolean) {
|
||||
this._mutex(() => {
|
||||
const oldXYWH =
|
||||
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
|
||||
const nextXYWH = this._getXYWH().serialize();
|
||||
|
||||
this._xywhDirty = false;
|
||||
|
||||
if (oldXYWH === nextXYWH && this._local.has('xywh')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._local.set('xywh', nextXYWH);
|
||||
this._local.delete('deserializedXYWH');
|
||||
|
||||
this._onChange({
|
||||
props: {
|
||||
xywh: nextXYWH,
|
||||
},
|
||||
oldValues: {
|
||||
xywh: oldXYWH,
|
||||
},
|
||||
local,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
abstract addChild(element: GfxModel): void;
|
||||
|
||||
/**
|
||||
* The actual field that stores the children of the group.
|
||||
* It should be a ymap decorated with `@field`.
|
||||
*/
|
||||
hasChild(element: GfxCompatibleInterface) {
|
||||
return this.childElements.includes(element as GfxModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the group has the given descendant.
|
||||
*/
|
||||
hasDescendant(element: GfxCompatibleInterface): boolean {
|
||||
return hasDescendantElementImpl(this, element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the child from the group
|
||||
*/
|
||||
abstract removeChild(element: GfxCompatibleInterface): void;
|
||||
|
||||
/**
|
||||
* Set the new value of the childIds
|
||||
* @param value the new value of the childIds
|
||||
* @param fromLocal if true, the change is happened in the local
|
||||
*/
|
||||
setChildIds(value: string[], fromLocal: boolean) {
|
||||
const oldChildIds = this.childIds;
|
||||
this._childIds = value;
|
||||
this.invalidateXYWH();
|
||||
|
||||
this._onChange({
|
||||
props: {
|
||||
childIds: value,
|
||||
},
|
||||
oldValues: {
|
||||
childIds: oldChildIds,
|
||||
},
|
||||
local: fromLocal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncElementFromY(
|
||||
model: GfxPrimitiveElementModel,
|
||||
callback: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void
|
||||
) {
|
||||
const disposables: Record<string, () => void> = {};
|
||||
const observer = (
|
||||
event: Y.YMapEvent<unknown>,
|
||||
transaction: Y.Transaction
|
||||
) => {
|
||||
const props: Record<string, unknown> = {};
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
|
||||
event.keysChanged.forEach(key => {
|
||||
const type = event.changes.keys.get(key);
|
||||
const oldValue = event.changes.keys.get(key)?.oldValue;
|
||||
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.action === 'update' || type.action === 'add') {
|
||||
const value = model.yMap.get(key);
|
||||
|
||||
if (value instanceof Y.Text) {
|
||||
disposables[key]?.();
|
||||
disposables[key] = watchText(key, value, callback);
|
||||
}
|
||||
|
||||
model['_preserved'].set(key, value);
|
||||
props[key] = value;
|
||||
oldValues[key] = oldValue;
|
||||
} else {
|
||||
model['_preserved'].delete(key);
|
||||
oldValues[key] = oldValue;
|
||||
}
|
||||
});
|
||||
|
||||
callback({
|
||||
props,
|
||||
oldValues,
|
||||
local: transaction.local,
|
||||
});
|
||||
};
|
||||
|
||||
Array.from(model.yMap.entries()).forEach(([key, value]) => {
|
||||
if (value instanceof Y.Text) {
|
||||
disposables[key] = watchText(key, value, callback);
|
||||
}
|
||||
|
||||
model['_preserved'].set(key, value);
|
||||
});
|
||||
|
||||
model.yMap.observe(observer);
|
||||
disposables['ymap'] = () => {
|
||||
model.yMap.unobserve(observer);
|
||||
};
|
||||
|
||||
return () => {
|
||||
Object.values(disposables).forEach(fn => fn());
|
||||
};
|
||||
}
|
||||
|
||||
function watchText(
|
||||
key: string,
|
||||
value: Y.Text,
|
||||
callback: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void
|
||||
) {
|
||||
const fn = (_: Y.YTextEvent, transaction: Y.Transaction) => {
|
||||
callback({
|
||||
props: {
|
||||
[key]: value,
|
||||
},
|
||||
oldValues: {},
|
||||
local: transaction.local,
|
||||
});
|
||||
};
|
||||
|
||||
value.observe(fn);
|
||||
|
||||
return () => {
|
||||
value.unobserve(fn);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { mutex } from 'lib0';
|
||||
|
||||
import type { EditorHost } from '../../../view/index.js';
|
||||
import type { GfxCompatibleInterface, PointTestOptions } from '../base.js';
|
||||
import type { GfxGroupModel, GfxModel } from '../model.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
|
||||
export function prop<V, T extends GfxLocalElementModel>() {
|
||||
return function propDecorator(
|
||||
_target: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: T, val: unknown) {
|
||||
this._props.add(prop);
|
||||
this._local.set(prop, val);
|
||||
},
|
||||
get(this: T) {
|
||||
return this._local.get(prop);
|
||||
},
|
||||
set(this: T, val: V) {
|
||||
this._local.set(prop, val);
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class GfxLocalElementModel implements GfxCompatibleInterface {
|
||||
private readonly _mutex: mutex.mutex = mutex.createMutex();
|
||||
|
||||
protected _local = new Map<string | symbol, unknown>();
|
||||
|
||||
/**
|
||||
* Used to store all the name of the properties that have been decorated
|
||||
* with the `@prop`
|
||||
*/
|
||||
protected _props = new Set<string | symbol>();
|
||||
|
||||
protected _surface: SurfaceBlockModel;
|
||||
|
||||
/**
|
||||
* used to store the properties' cache key
|
||||
* when the properties required heavy computation
|
||||
*/
|
||||
cache = new Map<string | symbol, unknown>();
|
||||
|
||||
id: string = '';
|
||||
|
||||
abstract readonly type: string;
|
||||
|
||||
creator: GfxModel | null = null;
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (!this._local.has('deserializedXYWH')) {
|
||||
const xywh = this.xywh;
|
||||
const deserialized = deserializeXYWH(xywh);
|
||||
|
||||
this._local.set('deserializedXYWH', deserialized);
|
||||
}
|
||||
|
||||
return this._local.get('deserializedXYWH') as XYWH;
|
||||
}
|
||||
|
||||
get elementBound() {
|
||||
return new Bound(this.x, this.y, this.w, this.h);
|
||||
}
|
||||
|
||||
get group() {
|
||||
return (
|
||||
this.groupId ? this._surface.getElementById(this.groupId) : null
|
||||
) as GfxGroupModel | null;
|
||||
}
|
||||
|
||||
get groups() {
|
||||
if (this.group) {
|
||||
const groups = this._surface.getGroups(this.group.id);
|
||||
groups.unshift(this.group);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this._surface;
|
||||
}
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
constructor(surfaceModel: SurfaceBlockModel) {
|
||||
this._surface = surfaceModel;
|
||||
|
||||
const p = new Proxy(this, {
|
||||
set: (target, prop, value) => {
|
||||
if (prop === 'xywh') {
|
||||
this._local.delete('deserializedXYWH');
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore
|
||||
const oldValue = target[prop as string];
|
||||
|
||||
if (oldValue === value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore
|
||||
target[prop as string] = value;
|
||||
|
||||
if (!this._props.has(prop)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (surfaceModel.localElementModels.has(p)) {
|
||||
this._mutex(() => {
|
||||
surfaceModel.localElementUpdated.next({
|
||||
model: p,
|
||||
props: {
|
||||
[prop as string]: value,
|
||||
},
|
||||
oldValues: {
|
||||
[prop as string]: oldValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line no-constructor-return
|
||||
return p;
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
return getPointsFromBoundWithRotation(this).some(point =>
|
||||
bounds.containsPoint(point)
|
||||
);
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return polygonNearestPoint(points, point);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec) {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0];
|
||||
const points = rotatePoints(bound.points, bound.center, this.rotate);
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
opt: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
|
||||
return bound.isPointInBound([x, y]);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
isLocked() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLockedByAncestor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLockedBySelf() {
|
||||
return false;
|
||||
}
|
||||
|
||||
lock() {
|
||||
return;
|
||||
}
|
||||
|
||||
unlock() {
|
||||
return;
|
||||
}
|
||||
|
||||
@prop()
|
||||
accessor groupId: string = '';
|
||||
|
||||
@prop()
|
||||
accessor hidden: boolean = false;
|
||||
|
||||
@prop()
|
||||
accessor index: string = 'a0';
|
||||
|
||||
@prop()
|
||||
accessor opacity: number = 1;
|
||||
|
||||
@prop()
|
||||
accessor responseExtension: [number, number] = [0, 0];
|
||||
|
||||
@prop()
|
||||
accessor rotate: number = 0;
|
||||
|
||||
@prop()
|
||||
accessor seed: number = Math.random();
|
||||
|
||||
@prop()
|
||||
accessor xywh: SerializedXYWH = '[0,0,0,0]';
|
||||
}
|
||||
940
blocksuite/framework/std/src/gfx/model/surface/surface-model.ts
Normal file
940
blocksuite/framework/std/src/gfx/model/surface/surface-model.ts
Normal file
@@ -0,0 +1,940 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { assertType, type Constructor } from '@blocksuite/global/utils';
|
||||
import type { Boxed } from '@blocksuite/store';
|
||||
import { BlockModel, nanoid } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { measureOperation } from '../../perf.js';
|
||||
import {
|
||||
type GfxGroupCompatibleInterface,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '../base.js';
|
||||
import type { GfxGroupModel, GfxModel } from '../model.js';
|
||||
import { createDecoratorState } from './decorators/common.js';
|
||||
import { initializeObservers, initializeWatchers } from './decorators/index.js';
|
||||
import {
|
||||
GfxGroupLikeElementModel,
|
||||
type GfxPrimitiveElementModel,
|
||||
syncElementFromY,
|
||||
} from './element-model.js';
|
||||
import type { GfxLocalElementModel } from './local-element-model.js';
|
||||
|
||||
/**
|
||||
* Used for text field
|
||||
*/
|
||||
export const SURFACE_TEXT_UNIQ_IDENTIFIER = 'affine:surface:text';
|
||||
/**
|
||||
* Used for field that use Y.Map. E.g. group children field
|
||||
*/
|
||||
export const SURFACE_YMAP_UNIQ_IDENTIFIER = 'affine:surface:ymap';
|
||||
|
||||
export type SurfaceBlockProps = {
|
||||
elements: Boxed<Y.Map<Y.Map<unknown>>>;
|
||||
};
|
||||
|
||||
export interface ElementUpdatedData {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
export type MiddlewareCtx = {
|
||||
type: 'beforeAdd';
|
||||
payload: {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void;
|
||||
|
||||
export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
private static readonly _groupBoundImpactKeys = new Set([
|
||||
'xywh',
|
||||
'rotate',
|
||||
'hidden',
|
||||
]);
|
||||
|
||||
protected _decoratorState = createDecoratorState();
|
||||
|
||||
protected _elementCtorMap: Record<
|
||||
string,
|
||||
Constructor<
|
||||
GfxPrimitiveElementModel,
|
||||
ConstructorParameters<typeof GfxPrimitiveElementModel>
|
||||
>
|
||||
> = Object.create(null);
|
||||
|
||||
protected _elementModels = new Map<
|
||||
string,
|
||||
{
|
||||
mount: () => void;
|
||||
unmount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}
|
||||
>();
|
||||
|
||||
protected _elementTypeMap = new Map<string, GfxPrimitiveElementModel[]>();
|
||||
|
||||
protected _groupLikeModels = new Map<string, GfxGroupModel>();
|
||||
|
||||
protected _parentGroupMap = new Map<string, string>();
|
||||
|
||||
protected _groupChildIdsMap = new Map<string, string[]>();
|
||||
|
||||
protected _middlewares: SurfaceMiddleware[] = [];
|
||||
|
||||
protected _surfaceBlockModel = true;
|
||||
|
||||
protected localElements = new Set<GfxLocalElementModel>();
|
||||
|
||||
elementAdded = new Subject<{ id: string; local: boolean }>();
|
||||
|
||||
elementRemoved = new Subject<{
|
||||
id: string;
|
||||
type: string;
|
||||
model: GfxPrimitiveElementModel;
|
||||
local: boolean;
|
||||
}>();
|
||||
|
||||
elementUpdated = new Subject<ElementUpdatedData>();
|
||||
|
||||
localElementAdded = new Subject<GfxLocalElementModel>();
|
||||
|
||||
localElementDeleted = new Subject<GfxLocalElementModel>();
|
||||
|
||||
localElementUpdated = new Subject<{
|
||||
model: GfxLocalElementModel;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
}>();
|
||||
|
||||
private readonly _isEmpty$ = signal(false);
|
||||
|
||||
get elementModels() {
|
||||
const models: GfxPrimitiveElementModel[] = [];
|
||||
this._elementModels.forEach(model => models.push(model.model));
|
||||
return models;
|
||||
}
|
||||
|
||||
get elements() {
|
||||
return this.props.elements;
|
||||
}
|
||||
|
||||
get localElementModels() {
|
||||
return this.localElements;
|
||||
}
|
||||
|
||||
get registeredElementTypes() {
|
||||
return Object.keys(this._elementCtorMap);
|
||||
}
|
||||
|
||||
override isEmpty(): boolean {
|
||||
return this._isEmpty$.value;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = this.created.subscribe(() => {
|
||||
this._init();
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private _collectElementsToDelete(
|
||||
id: string,
|
||||
deleteElementIds: Set<string>,
|
||||
orderedDeleteIds: string[],
|
||||
deleteBlockIds: Set<string>
|
||||
) {
|
||||
if (deleteElementIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = this.getElementById(id);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteElementIds.add(id);
|
||||
|
||||
if (element instanceof GfxGroupLikeElementModel) {
|
||||
element.childIds.forEach(childId => {
|
||||
if (this.hasElementById(childId)) {
|
||||
this._collectElementsToDelete(
|
||||
childId,
|
||||
deleteElementIds,
|
||||
orderedDeleteIds,
|
||||
deleteBlockIds
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.store.hasBlock(childId)) {
|
||||
deleteBlockIds.add(childId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
orderedDeleteIds.push(id);
|
||||
}
|
||||
|
||||
private _createElementFromProps(
|
||||
props: Record<string, unknown>,
|
||||
options: {
|
||||
onChange: (payload: {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
}
|
||||
) {
|
||||
const { type, id, ...rest } = props;
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Cannot find id in props');
|
||||
}
|
||||
|
||||
const yMap = new Y.Map();
|
||||
const elementModel = this._createElementFromYMap(
|
||||
type as string,
|
||||
id as string,
|
||||
yMap,
|
||||
{
|
||||
...options,
|
||||
newCreate: true,
|
||||
}
|
||||
);
|
||||
|
||||
props = this._propsToY(type as string, props);
|
||||
|
||||
yMap.set('type', type);
|
||||
yMap.set('id', id);
|
||||
|
||||
Object.keys(rest).forEach(key => {
|
||||
if (props[key] !== undefined) {
|
||||
// @ts-expect-error ignore
|
||||
elementModel.model[key] = props[key];
|
||||
}
|
||||
});
|
||||
|
||||
return elementModel;
|
||||
}
|
||||
|
||||
private _createElementFromYMap(
|
||||
type: string,
|
||||
id: string,
|
||||
yMap: Y.Map<unknown>,
|
||||
options: {
|
||||
onChange: (payload: {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
skipFieldInit?: boolean;
|
||||
newCreate?: boolean;
|
||||
}
|
||||
) {
|
||||
const stashed = new Map<string | symbol, unknown>();
|
||||
const Ctor = this._elementCtorMap[type];
|
||||
|
||||
if (!Ctor) {
|
||||
throw new Error(`Invalid element type: ${yMap.get('type')}`);
|
||||
}
|
||||
const state = this._decoratorState;
|
||||
|
||||
state.creating = true;
|
||||
state.skipField = options.skipFieldInit ?? false;
|
||||
|
||||
let mounted = false;
|
||||
// @ts-expect-error ignore
|
||||
Ctor['_decoratorState'] = state;
|
||||
|
||||
const elementModel = new Ctor({
|
||||
id,
|
||||
yMap,
|
||||
model: this,
|
||||
stashedStore: stashed,
|
||||
onChange: payload => mounted && options.onChange({ id, ...payload }),
|
||||
}) as GfxPrimitiveElementModel;
|
||||
|
||||
// @ts-expect-error ignore
|
||||
delete Ctor['_decoratorState'];
|
||||
state.creating = false;
|
||||
state.skipField = false;
|
||||
|
||||
const unmount = () => {
|
||||
mounted = false;
|
||||
elementModel.onDestroyed();
|
||||
};
|
||||
|
||||
const mount = () => {
|
||||
initializeObservers(Ctor.prototype, elementModel);
|
||||
initializeWatchers(Ctor.prototype, elementModel);
|
||||
elementModel['_disposable'].add(
|
||||
syncElementFromY(elementModel, payload => {
|
||||
mounted &&
|
||||
options.onChange({
|
||||
id,
|
||||
...payload,
|
||||
});
|
||||
})
|
||||
);
|
||||
mounted = true;
|
||||
elementModel.onCreated();
|
||||
};
|
||||
|
||||
return {
|
||||
model: elementModel,
|
||||
mount,
|
||||
unmount,
|
||||
};
|
||||
}
|
||||
|
||||
private _emitElementUpdated(
|
||||
model: GfxPrimitiveElementModel,
|
||||
payload: ElementUpdatedData
|
||||
) {
|
||||
if (
|
||||
isGfxGroupCompatibleModel(model) &&
|
||||
('childIds' in payload.props || 'childIds' in payload.oldValues)
|
||||
) {
|
||||
const oldChildIds = Array.isArray(payload.oldValues['childIds'])
|
||||
? (payload.oldValues['childIds'] as string[])
|
||||
: undefined;
|
||||
this._syncGroupChildrenIndex(model.id, model.childIds, oldChildIds);
|
||||
}
|
||||
|
||||
this.elementUpdated.next(payload);
|
||||
Object.keys(payload.props).forEach(key => {
|
||||
model.propsUpdated.next({ key });
|
||||
});
|
||||
|
||||
this._refreshParentGroupBoundsForElement(model, payload);
|
||||
}
|
||||
|
||||
private _refreshParentGroupBounds(id: string, local: boolean) {
|
||||
const group = this.getGroup(id);
|
||||
|
||||
if (group instanceof GfxGroupLikeElementModel) {
|
||||
group.refreshXYWH(local);
|
||||
}
|
||||
}
|
||||
|
||||
private _refreshParentGroupBoundsForElement(
|
||||
model: GfxPrimitiveElementModel,
|
||||
payload: ElementUpdatedData
|
||||
) {
|
||||
if (
|
||||
model instanceof GfxGroupLikeElementModel &&
|
||||
('childIds' in payload.props || 'childIds' in payload.oldValues)
|
||||
) {
|
||||
model.refreshXYWH(payload.local);
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedKeys = new Set([
|
||||
...Object.keys(payload.props),
|
||||
...Object.keys(payload.oldValues),
|
||||
]);
|
||||
|
||||
if (
|
||||
Array.from(affectedKeys).some(key =>
|
||||
SurfaceBlockModel._groupBoundImpactKeys.has(key)
|
||||
)
|
||||
) {
|
||||
this._refreshParentGroupBounds(model.id, payload.local);
|
||||
}
|
||||
}
|
||||
|
||||
private _initElementModels() {
|
||||
const elementsYMap = this.elements.getValue()!;
|
||||
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||
const sameTypeElements = this._elementTypeMap.get(type) || [];
|
||||
|
||||
if (sameTypeElements.indexOf(model) === -1) {
|
||||
sameTypeElements.push(model);
|
||||
}
|
||||
|
||||
this._elementTypeMap.set(type, sameTypeElements);
|
||||
|
||||
if (isGfxGroupCompatibleModel(model)) {
|
||||
this._groupLikeModels.set(model.id, model);
|
||||
this._syncGroupChildrenIndex(model.id, model.childIds, []);
|
||||
}
|
||||
};
|
||||
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||
const sameTypeElements = this._elementTypeMap.get(type) || [];
|
||||
const index = sameTypeElements.indexOf(model);
|
||||
|
||||
if (index !== -1) {
|
||||
sameTypeElements.splice(index, 1);
|
||||
}
|
||||
|
||||
this._parentGroupMap.delete(model.id);
|
||||
|
||||
if (isGfxGroupCompatibleModel(model)) {
|
||||
this._removeGroupFromChildrenIndex(model.id);
|
||||
this._groupLikeModels.delete(model.id);
|
||||
}
|
||||
};
|
||||
const onElementsMapChange = (
|
||||
event: Y.YMapEvent<Y.Map<unknown>>,
|
||||
transaction: Y.Transaction
|
||||
) => {
|
||||
const { changes, keysChanged } = event;
|
||||
const addedElements: {
|
||||
mount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}[] = [];
|
||||
const deletedElements: {
|
||||
unmount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}[] = [];
|
||||
|
||||
keysChanged.forEach(id => {
|
||||
const change = changes.keys.get(id);
|
||||
const element = this.elements.getValue()!.get(id);
|
||||
|
||||
switch (change?.action) {
|
||||
case 'add':
|
||||
if (element) {
|
||||
const hasModel = this._elementModels.has(id);
|
||||
const model = hasModel
|
||||
? this._elementModels.get(id)!
|
||||
: this._createElementFromYMap(
|
||||
element.get('type') as string,
|
||||
element.get('id') as string,
|
||||
element,
|
||||
{
|
||||
onChange: payload => {
|
||||
this._emitElementUpdated(model.model, {
|
||||
...payload,
|
||||
id,
|
||||
});
|
||||
},
|
||||
skipFieldInit: true,
|
||||
}
|
||||
);
|
||||
|
||||
!hasModel && this._elementModels.set(id, model);
|
||||
addToType(model.model.type, model.model);
|
||||
addedElements.push(model);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (this._elementModels.has(id)) {
|
||||
const { model, unmount } = this._elementModels.get(id)!;
|
||||
removeFromType(model.type, model);
|
||||
this._elementModels.delete(id);
|
||||
deletedElements.push({ model, unmount });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
addedElements.forEach(({ mount, model }) => {
|
||||
mount();
|
||||
this.elementAdded.next({ id: model.id, local: transaction.local });
|
||||
});
|
||||
deletedElements.forEach(({ unmount, model }) => {
|
||||
unmount();
|
||||
this.elementRemoved.next({
|
||||
id: model.id,
|
||||
type: model.type,
|
||||
model,
|
||||
local: transaction.local,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
elementsYMap.forEach((val, key) => {
|
||||
const model = this._createElementFromYMap(
|
||||
val.get('type') as string,
|
||||
val.get('id') as string,
|
||||
val,
|
||||
{
|
||||
onChange: payload => {
|
||||
this._emitElementUpdated(model.model, {
|
||||
...payload,
|
||||
id: key,
|
||||
});
|
||||
},
|
||||
skipFieldInit: true,
|
||||
}
|
||||
);
|
||||
|
||||
this._elementModels.set(key, model);
|
||||
});
|
||||
|
||||
this._elementModels.forEach(({ mount, model }) => {
|
||||
addToType(model.type, model);
|
||||
mount();
|
||||
});
|
||||
|
||||
Object.values(this.store.blocks.peek()).forEach(block => {
|
||||
if (isGfxGroupCompatibleModel(block.model)) {
|
||||
this._groupLikeModels.set(block.id, block.model);
|
||||
this._syncGroupChildrenIndex(block.id, block.model.childIds, []);
|
||||
}
|
||||
});
|
||||
|
||||
this._rebuildGroupChildrenIndex();
|
||||
|
||||
elementsYMap.observe(onElementsMapChange);
|
||||
|
||||
const subscription = this.store.slots.blockUpdated.subscribe(payload => {
|
||||
switch (payload.type) {
|
||||
case 'add':
|
||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||
this._groupLikeModels.set(payload.id, payload.model);
|
||||
this._syncGroupChildrenIndex(
|
||||
payload.id,
|
||||
payload.model.childIds,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.model instanceof BlockModel) {
|
||||
this._refreshParentGroupBounds(payload.id, payload.isLocal);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'delete':
|
||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||
this._removeGroupFromChildrenIndex(payload.id);
|
||||
this._groupLikeModels.delete(payload.id);
|
||||
}
|
||||
{
|
||||
const group = this.getGroup(payload.id);
|
||||
if (group) {
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group.removeChild(payload.model as GfxModel);
|
||||
}
|
||||
}
|
||||
this._parentGroupMap.delete(payload.id);
|
||||
|
||||
break;
|
||||
case 'update':
|
||||
if (payload.props.key === 'childElementIds') {
|
||||
const group = this.store.getBlock(payload.id)?.model;
|
||||
if (group && isGfxGroupCompatibleModel(group)) {
|
||||
this._syncGroupChildrenIndex(group.id, group.childIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
payload.props.key &&
|
||||
SurfaceBlockModel._groupBoundImpactKeys.has(payload.props.key)
|
||||
) {
|
||||
this._refreshParentGroupBounds(payload.id, payload.isLocal);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.deleted.subscribe(() => {
|
||||
elementsYMap.unobserve(onElementsMapChange);
|
||||
subscription.unsubscribe();
|
||||
this._groupChildIdsMap.clear();
|
||||
this._parentGroupMap.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private _propsToY(type: string, props: Record<string, unknown>) {
|
||||
const ctor = this._elementCtorMap[type];
|
||||
|
||||
if (!ctor) {
|
||||
throw new Error(`Invalid element type: ${type}`);
|
||||
}
|
||||
|
||||
Object.entries(props).forEach(([key, val]) => {
|
||||
if (val instanceof Object) {
|
||||
if (Reflect.has(val, SURFACE_TEXT_UNIQ_IDENTIFIER)) {
|
||||
const yText = new Y.Text();
|
||||
yText.applyDelta(Reflect.get(val, 'delta'));
|
||||
Reflect.set(props, key, yText);
|
||||
}
|
||||
|
||||
if (Reflect.has(val, SURFACE_YMAP_UNIQ_IDENTIFIER)) {
|
||||
const childJson = Reflect.get(val, 'json') as Record<string, unknown>;
|
||||
const childrenYMap = new Y.Map<unknown>();
|
||||
|
||||
Object.keys(childJson).forEach(childId => {
|
||||
childrenYMap.set(childId, childJson[childId]);
|
||||
});
|
||||
Reflect.set(props, key, childrenYMap);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-expect-error ignore
|
||||
return ctor.propsToY ? ctor.propsToY(props) : props;
|
||||
}
|
||||
|
||||
private _watchGroupRelationChange() {
|
||||
const isGroup = (
|
||||
element: GfxPrimitiveElementModel
|
||||
): element is GfxGroupLikeElementModel =>
|
||||
element instanceof GfxGroupLikeElementModel;
|
||||
|
||||
const disposable = this.elementUpdated.subscribe(({ id, oldValues }) => {
|
||||
const element = this.getElementById(id)!;
|
||||
|
||||
if (
|
||||
isGroup(element) &&
|
||||
oldValues['childIds'] &&
|
||||
element.childIds.length === 0
|
||||
) {
|
||||
this.deleteElement(id);
|
||||
}
|
||||
});
|
||||
this.deleted.subscribe(() => {
|
||||
disposable.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private _watchChildrenChange() {
|
||||
const updateIsEmpty = () => {
|
||||
this._isEmpty$.value =
|
||||
this._elementModels.size === 0 && this.children.length === 0;
|
||||
};
|
||||
|
||||
const disposables = new DisposableGroup();
|
||||
disposables.add(this.elementAdded.subscribe(updateIsEmpty));
|
||||
disposables.add(this.elementRemoved.subscribe(updateIsEmpty));
|
||||
this.store.slots.blockUpdated.subscribe(payload => {
|
||||
if (['add', 'delete'].includes(payload.type)) {
|
||||
updateIsEmpty();
|
||||
}
|
||||
});
|
||||
this.deleted.subscribe(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
protected _extendElement(
|
||||
ctorMap: Record<
|
||||
string,
|
||||
Constructor<
|
||||
GfxPrimitiveElementModel,
|
||||
ConstructorParameters<typeof GfxPrimitiveElementModel>
|
||||
>
|
||||
>
|
||||
) {
|
||||
Object.assign(this._elementCtorMap, ctorMap);
|
||||
}
|
||||
|
||||
protected _init() {
|
||||
this._initElementModels();
|
||||
this._watchGroupRelationChange();
|
||||
this._watchChildrenChange();
|
||||
}
|
||||
|
||||
getConstructor(type: string) {
|
||||
return this._elementCtorMap[type];
|
||||
}
|
||||
|
||||
private _rebuildGroupChildrenIndex() {
|
||||
this._groupChildIdsMap.clear();
|
||||
this._parentGroupMap.clear();
|
||||
|
||||
this._groupLikeModels.forEach(group => {
|
||||
this._syncGroupChildrenIndex(group.id, group.childIds, []);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeFromParentGroupIfNeeded(
|
||||
element: GfxModel,
|
||||
deleteElementIds: Set<string>
|
||||
) {
|
||||
const parentGroupId = this._parentGroupMap.get(element.id);
|
||||
|
||||
if (parentGroupId && deleteElementIds.has(parentGroupId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parentGroup: GfxGroupModel | null = null;
|
||||
|
||||
if (parentGroupId) {
|
||||
parentGroup = this._groupLikeModels.get(parentGroupId) ?? null;
|
||||
}
|
||||
|
||||
parentGroup = parentGroup ?? this.getGroup(element.id);
|
||||
|
||||
if (parentGroup && !deleteElementIds.has(parentGroup.id)) {
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
parentGroup.removeChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
private _removeGroupFromChildrenIndex(groupId: string) {
|
||||
const previousChildIds = this._groupChildIdsMap.get(groupId) ?? [];
|
||||
|
||||
previousChildIds.forEach(childId => {
|
||||
if (this._parentGroupMap.get(childId) === groupId) {
|
||||
this._parentGroupMap.delete(childId);
|
||||
}
|
||||
});
|
||||
|
||||
this._groupChildIdsMap.delete(groupId);
|
||||
}
|
||||
|
||||
private _syncGroupChildrenIndex(
|
||||
groupId: string,
|
||||
nextChildIds: string[],
|
||||
previousChildIds?: string[]
|
||||
) {
|
||||
const prev = previousChildIds ?? this._groupChildIdsMap.get(groupId) ?? [];
|
||||
|
||||
prev.forEach(childId => {
|
||||
if (this._parentGroupMap.get(childId) === groupId) {
|
||||
this._parentGroupMap.delete(childId);
|
||||
}
|
||||
});
|
||||
|
||||
nextChildIds.forEach(childId => {
|
||||
this._parentGroupMap.set(childId, groupId);
|
||||
});
|
||||
|
||||
this._groupChildIdsMap.set(groupId, [...nextChildIds]);
|
||||
}
|
||||
|
||||
addElement<T extends object = Record<string, unknown>>(
|
||||
props: Partial<T> & { type: string }
|
||||
) {
|
||||
if (this.store.readonly) {
|
||||
throw new Error('Cannot add element in readonly mode');
|
||||
}
|
||||
|
||||
const middlewareCtx: MiddlewareCtx = {
|
||||
type: 'beforeAdd',
|
||||
payload: {
|
||||
type: props.type,
|
||||
props,
|
||||
},
|
||||
};
|
||||
|
||||
this._middlewares.forEach(mid => mid(middlewareCtx));
|
||||
|
||||
props = middlewareCtx.payload.props as Partial<T> & { type: string };
|
||||
|
||||
const id = nanoid();
|
||||
|
||||
// @ts-expect-error ignore
|
||||
props.id = id;
|
||||
|
||||
const elementModel = this._createElementFromProps(props, {
|
||||
onChange: payload => {
|
||||
this._emitElementUpdated(elementModel.model, {
|
||||
...payload,
|
||||
id,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this._elementModels.set(id, elementModel);
|
||||
|
||||
this.store.transact(() => {
|
||||
this.elements.getValue()!.set(id, elementModel.model.yMap);
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
addLocalElement(elem: GfxLocalElementModel) {
|
||||
this.localElements.add(elem);
|
||||
this.localElementAdded.next(elem);
|
||||
}
|
||||
|
||||
applyMiddlewares(middlewares: SurfaceMiddleware[]) {
|
||||
this._middlewares = middlewares;
|
||||
}
|
||||
|
||||
deleteElement(id: string) {
|
||||
if (this.store.readonly) {
|
||||
throw new Error('Cannot remove element in readonly mode');
|
||||
}
|
||||
|
||||
if (!this.hasElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
measureOperation('edgeless:delete-element', () => {
|
||||
const deleteElementIds = new Set<string>();
|
||||
const orderedDeleteIds: string[] = [];
|
||||
const deleteBlockIds = new Set<string>();
|
||||
|
||||
this._collectElementsToDelete(
|
||||
id,
|
||||
deleteElementIds,
|
||||
orderedDeleteIds,
|
||||
deleteBlockIds
|
||||
);
|
||||
|
||||
if (orderedDeleteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.transact(() => {
|
||||
orderedDeleteIds.forEach(elementId => {
|
||||
const element = this.getElementById(elementId);
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._removeFromParentGroupIfNeeded(element, deleteElementIds);
|
||||
this.elements.getValue()!.delete(elementId);
|
||||
});
|
||||
|
||||
deleteBlockIds.forEach(blockId => {
|
||||
const block = this.store.getBlock(blockId)?.model;
|
||||
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._removeFromParentGroupIfNeeded(
|
||||
block as GfxModel,
|
||||
deleteElementIds
|
||||
);
|
||||
this.store.deleteBlock(block);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteLocalElement(elem: GfxLocalElementModel) {
|
||||
if (this.localElements.delete(elem)) {
|
||||
this.localElementDeleted.next(elem);
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
|
||||
this.elementAdded.complete();
|
||||
this.elementRemoved.complete();
|
||||
this.elementUpdated.complete();
|
||||
|
||||
this._elementModels.forEach(({ unmount }) => unmount());
|
||||
this._elementModels.clear();
|
||||
}
|
||||
|
||||
getElementById(id: string): GfxPrimitiveElementModel | null {
|
||||
return this._elementModels.get(id)?.model ?? null;
|
||||
}
|
||||
|
||||
getElementsByType(type: string): GfxPrimitiveElementModel[] {
|
||||
return this._elementTypeMap.get(type) || [];
|
||||
}
|
||||
|
||||
getGroup(elem: string | GfxModel): GfxGroupModel | null {
|
||||
const id = typeof elem === 'string' ? elem : elem.id;
|
||||
const parentGroupId = this._parentGroupMap.get(id);
|
||||
|
||||
if (parentGroupId) {
|
||||
const group = this._groupLikeModels.get(parentGroupId);
|
||||
if (group) {
|
||||
return group;
|
||||
}
|
||||
|
||||
this._parentGroupMap.delete(id);
|
||||
}
|
||||
|
||||
const model =
|
||||
typeof elem === 'string'
|
||||
? ((this.getElementById(elem) ??
|
||||
this.store.getBlock(elem)?.model) as GfxModel)
|
||||
: elem;
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
assertType<GfxModel>(model);
|
||||
|
||||
for (const group of this._groupLikeModels.values()) {
|
||||
if (group.hasChild(model)) {
|
||||
this._parentGroupMap.set(id, group.id);
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all groups in the group chain. The last group is the top level group.
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
getGroups(id: string): GfxGroupModel[] {
|
||||
const groups: GfxGroupModel[] = [];
|
||||
const visited = new Set<GfxGroupModel>();
|
||||
let group = this.getGroup(id);
|
||||
|
||||
while (group) {
|
||||
if (visited.has(group)) {
|
||||
console.warn('Exists a cycle in group relation');
|
||||
break;
|
||||
}
|
||||
visited.add(group);
|
||||
groups.push(group);
|
||||
group = this.getGroup(group.id);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
hasElementById(id: string): boolean {
|
||||
return this._elementModels.has(id);
|
||||
}
|
||||
|
||||
isGroup(element: GfxModel): element is GfxModel & GfxGroupCompatibleInterface;
|
||||
isGroup(id: string): boolean;
|
||||
isGroup(element: string | GfxModel): boolean {
|
||||
if (typeof element === 'string') {
|
||||
const el = this.getElementById(element);
|
||||
if (el) return isGfxGroupCompatibleModel(el);
|
||||
|
||||
const blockModel = this.store.getBlock(element)?.model;
|
||||
if (blockModel) return isGfxGroupCompatibleModel(blockModel);
|
||||
|
||||
return false;
|
||||
} else {
|
||||
return isGfxGroupCompatibleModel(element);
|
||||
}
|
||||
}
|
||||
|
||||
updateElement<T extends object = Record<string, unknown>>(
|
||||
id: string,
|
||||
props: Partial<T>
|
||||
) {
|
||||
if (this.store.readonly) {
|
||||
throw new Error('Cannot update element in readonly mode');
|
||||
}
|
||||
|
||||
const elementModel = this.getElementById(id);
|
||||
|
||||
if (!elementModel) {
|
||||
throw new Error(`Element ${id} is not found`);
|
||||
}
|
||||
|
||||
this.store.transact(() => {
|
||||
props = this._propsToY(
|
||||
elementModel.type,
|
||||
props as Record<string, unknown>
|
||||
) as T;
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
// @ts-expect-error ignore
|
||||
elementModel[key] = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
31
blocksuite/framework/std/src/gfx/perf.ts
Normal file
31
blocksuite/framework/std/src/gfx/perf.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
let opMeasureSeq = 0;
|
||||
|
||||
/**
|
||||
* Measure operation cost via Performance API when available.
|
||||
*
|
||||
* Marks are always cleared, while measure entries are intentionally retained
|
||||
* so callers can inspect them from Performance tools.
|
||||
*/
|
||||
export const measureOperation = <T>(name: string, fn: () => T): T => {
|
||||
if (
|
||||
typeof performance === 'undefined' ||
|
||||
typeof performance.mark !== 'function' ||
|
||||
typeof performance.measure !== 'function'
|
||||
) {
|
||||
return fn();
|
||||
}
|
||||
|
||||
const operationId = opMeasureSeq++;
|
||||
const startMark = `${name}:${operationId}:start`;
|
||||
const endMark = `${name}:${operationId}:end`;
|
||||
performance.mark(startMark);
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
performance.mark(endMark);
|
||||
performance.measure(name, startMark, endMark);
|
||||
performance.clearMarks(startMark);
|
||||
performance.clearMarks(endMark);
|
||||
}
|
||||
};
|
||||
76
blocksuite/framework/std/src/gfx/raf-coalescer.ts
Normal file
76
blocksuite/framework/std/src/gfx/raf-coalescer.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface RafCoalescer<T> {
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
schedule: (payload: T) => void;
|
||||
}
|
||||
|
||||
type FrameScheduler = (callback: FrameRequestCallback) => number;
|
||||
type FrameCanceller = (id: number) => void;
|
||||
|
||||
const getFrameScheduler = (): FrameScheduler => {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
return requestAnimationFrame;
|
||||
}
|
||||
|
||||
return callback => {
|
||||
return globalThis.setTimeout(() => {
|
||||
callback(
|
||||
typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
);
|
||||
}, 16) as unknown as number;
|
||||
};
|
||||
};
|
||||
|
||||
const getFrameCanceller = (): FrameCanceller => {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
return cancelAnimationFrame;
|
||||
}
|
||||
|
||||
return id => globalThis.clearTimeout(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Coalesce high-frequency updates and only process the latest payload in one frame.
|
||||
*/
|
||||
export const createRafCoalescer = <T>(
|
||||
apply: (payload: T) => void
|
||||
): RafCoalescer<T> => {
|
||||
const scheduleFrame = getFrameScheduler();
|
||||
const cancelFrame = getFrameCanceller();
|
||||
|
||||
let pendingPayload: T | undefined;
|
||||
let hasPendingPayload = false;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const run = () => {
|
||||
rafId = null;
|
||||
if (!hasPendingPayload) return;
|
||||
|
||||
const payload = pendingPayload as T;
|
||||
pendingPayload = undefined;
|
||||
hasPendingPayload = false;
|
||||
apply(payload);
|
||||
};
|
||||
|
||||
return {
|
||||
schedule(payload: T) {
|
||||
pendingPayload = payload;
|
||||
hasPendingPayload = true;
|
||||
|
||||
if (rafId !== null) return;
|
||||
rafId = scheduleFrame(run);
|
||||
},
|
||||
flush() {
|
||||
if (rafId !== null) cancelFrame(rafId);
|
||||
run();
|
||||
},
|
||||
cancel() {
|
||||
if (rafId !== null) {
|
||||
cancelFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
pendingPayload = undefined;
|
||||
hasPendingPayload = false;
|
||||
},
|
||||
};
|
||||
};
|
||||
408
blocksuite/framework/std/src/gfx/selection.ts
Normal file
408
blocksuite/framework/std/src/gfx/selection.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
getCommonBoundWithRotation,
|
||||
type IPoint,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import {
|
||||
BlockSelection,
|
||||
CursorSelection,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
} from '../selection/index.js';
|
||||
import type { GfxController } from './controller.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from './extension.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import { GfxGroupLikeElementModel } from './model/surface/element-model.js';
|
||||
|
||||
export interface SurfaceSelectionState {
|
||||
/**
|
||||
* The selected elements. Could be blocks or canvas elements
|
||||
*/
|
||||
elements: string[];
|
||||
|
||||
/**
|
||||
* Indicate whether the selected element is in editing mode
|
||||
*/
|
||||
editing?: boolean;
|
||||
|
||||
/**
|
||||
* Cannot be operated, only box is displayed
|
||||
*/
|
||||
inoperable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GfxSelectionManager is just a wrapper of std selection providing
|
||||
* convenient method and states in gfx
|
||||
*/
|
||||
export class GfxSelectionManager extends GfxExtension {
|
||||
static override key = 'gfxSelection';
|
||||
|
||||
private _activeGroup: GfxGroupLikeElementModel | null = null;
|
||||
|
||||
private _cursorSelection: CursorSelection | null = null;
|
||||
|
||||
private _lastSurfaceSelections: SurfaceSelection[] = [];
|
||||
|
||||
private _remoteCursorSelectionMap = new Map<number, CursorSelection>();
|
||||
|
||||
private _remoteSelectedSet = new Set<string>();
|
||||
|
||||
private _remoteSurfaceSelectionsMap = new Map<number, SurfaceSelection[]>();
|
||||
|
||||
private _selectedSet = new Set<string>();
|
||||
|
||||
private _surfaceSelections: SurfaceSelection[] = [];
|
||||
|
||||
disposable: DisposableGroup = new DisposableGroup();
|
||||
|
||||
readonly slots = {
|
||||
updated: new Subject<SurfaceSelection[]>(),
|
||||
remoteUpdated: new Subject<void>(),
|
||||
|
||||
cursorUpdated: new Subject<CursorSelection>(),
|
||||
remoteCursorUpdated: new Subject<void>(),
|
||||
};
|
||||
|
||||
get activeGroup() {
|
||||
return this._activeGroup;
|
||||
}
|
||||
|
||||
get cursorSelection() {
|
||||
return this._cursorSelection;
|
||||
}
|
||||
|
||||
get editing() {
|
||||
return this.surfaceSelections.some(sel => sel.editing);
|
||||
}
|
||||
|
||||
get empty() {
|
||||
return this.surfaceSelections.every(sel => sel.elements.length === 0);
|
||||
}
|
||||
|
||||
get firstElement() {
|
||||
return this.selectedElements[0];
|
||||
}
|
||||
|
||||
get inoperable() {
|
||||
return this.surfaceSelections.some(sel => sel.inoperable);
|
||||
}
|
||||
|
||||
get lastSurfaceSelections() {
|
||||
return this._lastSurfaceSelections;
|
||||
}
|
||||
|
||||
get remoteCursorSelectionMap() {
|
||||
return this._remoteCursorSelectionMap;
|
||||
}
|
||||
|
||||
get remoteSelectedSet() {
|
||||
return this._remoteSelectedSet;
|
||||
}
|
||||
|
||||
get remoteSurfaceSelectionsMap() {
|
||||
return this._remoteSurfaceSelectionsMap;
|
||||
}
|
||||
|
||||
get selectedBound() {
|
||||
return getCommonBoundWithRotation(this.selectedElements);
|
||||
}
|
||||
|
||||
get selectedElements() {
|
||||
const elements: GfxModel[] = [];
|
||||
|
||||
this.selectedIds.forEach(id => {
|
||||
const el = this.gfx.getElementById(id) as GfxModel;
|
||||
el && elements.push(el);
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
get selectedIds() {
|
||||
return [...this._selectedSet];
|
||||
}
|
||||
|
||||
get selectedSet() {
|
||||
return this._selectedSet;
|
||||
}
|
||||
|
||||
get stdSelection() {
|
||||
return this.std.selection;
|
||||
}
|
||||
|
||||
get surfaceModel() {
|
||||
return this.gfx.surface;
|
||||
}
|
||||
|
||||
get surfaceSelections() {
|
||||
return this._surfaceSelections;
|
||||
}
|
||||
|
||||
static override extendGfx(gfx: GfxController): void {
|
||||
Object.defineProperty(gfx, 'selection', {
|
||||
get() {
|
||||
return this.std.get(GfxExtensionIdentifier('gfxSelection'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.stdSelection.clear();
|
||||
|
||||
this.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
clearLast() {
|
||||
this._lastSurfaceSelections = [];
|
||||
}
|
||||
|
||||
equals(selection: SurfaceSelection[]) {
|
||||
let count = 0;
|
||||
let editing = false;
|
||||
const exist = selection.every(sel => {
|
||||
const exist = sel.elements.every(id => this._selectedSet.has(id));
|
||||
|
||||
if (exist) {
|
||||
count += sel.elements.length;
|
||||
}
|
||||
|
||||
if (sel.editing) editing = true;
|
||||
|
||||
return exist;
|
||||
});
|
||||
|
||||
return (
|
||||
exist && count === this._selectedSet.size && editing === this.editing
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the element is selected in local
|
||||
* @param element
|
||||
*/
|
||||
has(element: string) {
|
||||
return this._selectedSet.has(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* check if element is selected by remote peers
|
||||
* @param element
|
||||
*/
|
||||
hasRemote(element: string) {
|
||||
return this._remoteSelectedSet.has(element);
|
||||
}
|
||||
|
||||
isEmpty(selections: SurfaceSelection[]) {
|
||||
return selections.every(sel => sel.elements.length === 0);
|
||||
}
|
||||
|
||||
isInSelectedRect(modelX: number, modelY: number) {
|
||||
const selected = this.selectedElements;
|
||||
if (!selected.length) return false;
|
||||
|
||||
const commonBound = getCommonBoundWithRotation(selected);
|
||||
if (commonBound && commonBound.isPointInBound([modelX, modelY])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
this.disposable.add(
|
||||
this.stdSelection.slots.changed.subscribe(selections => {
|
||||
const { cursor = [], surface = [] } = groupBy(selections, sel => {
|
||||
if (sel.is(SurfaceSelection)) {
|
||||
return 'surface';
|
||||
} else if (sel.is(CursorSelection)) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
});
|
||||
|
||||
assertType<CursorSelection[]>(cursor);
|
||||
assertType<SurfaceSelection[]>(surface);
|
||||
|
||||
if (cursor[0] && !this.cursorSelection?.equals(cursor[0])) {
|
||||
this._cursorSelection = cursor[0];
|
||||
this.slots.cursorUpdated.next(cursor[0]);
|
||||
}
|
||||
|
||||
if ((surface.length === 0 && this.empty) || this.equals(surface)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastSurfaceSelections = this.surfaceSelections;
|
||||
this._surfaceSelections = surface;
|
||||
this._selectedSet = new Set<string>();
|
||||
|
||||
surface.forEach(sel =>
|
||||
sel.elements.forEach(id => {
|
||||
this._selectedSet.add(id);
|
||||
})
|
||||
);
|
||||
|
||||
this.slots.updated.next(this.surfaceSelections);
|
||||
})
|
||||
);
|
||||
|
||||
this.disposable.add(
|
||||
this.stdSelection.slots.remoteChanged.subscribe(states => {
|
||||
const surfaceMap = new Map<number, SurfaceSelection[]>();
|
||||
const cursorMap = new Map<number, CursorSelection>();
|
||||
const selectedSet = new Set<string>();
|
||||
|
||||
states.forEach((selections, id) => {
|
||||
let hasTextSelection = false;
|
||||
let hasBlockSelection = false;
|
||||
|
||||
selections.forEach(selection => {
|
||||
if (selection.is(TextSelection)) {
|
||||
hasTextSelection = true;
|
||||
}
|
||||
|
||||
if (selection.is(BlockSelection)) {
|
||||
hasBlockSelection = true;
|
||||
}
|
||||
|
||||
if (selection.is(SurfaceSelection)) {
|
||||
const surfaceSelections = surfaceMap.get(id) ?? [];
|
||||
surfaceSelections.push(selection);
|
||||
surfaceMap.set(id, surfaceSelections);
|
||||
|
||||
selection.elements.forEach(id => selectedSet.add(id));
|
||||
}
|
||||
|
||||
if (selection.is(CursorSelection)) {
|
||||
cursorMap.set(id, selection);
|
||||
}
|
||||
});
|
||||
|
||||
if (hasBlockSelection || hasTextSelection) {
|
||||
surfaceMap.delete(id);
|
||||
}
|
||||
|
||||
if (hasTextSelection) {
|
||||
cursorMap.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
this._remoteCursorSelectionMap = cursorMap;
|
||||
this._remoteSurfaceSelectionsMap = surfaceMap;
|
||||
this._remoteSelectedSet = selectedSet;
|
||||
|
||||
this.slots.remoteUpdated.next();
|
||||
this.slots.remoteCursorUpdated.next();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
set(selection: SurfaceSelectionState | SurfaceSelection[]) {
|
||||
if (Array.isArray(selection)) {
|
||||
this.stdSelection.setGroup(
|
||||
'gfx',
|
||||
this.cursorSelection ? [...selection, this.cursorSelection] : selection
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { blocks = [], elements = [] } = groupBy(selection.elements, id => {
|
||||
return this.std.store.hasBlock(id) ? 'blocks' : 'elements';
|
||||
});
|
||||
let instances: (SurfaceSelection | CursorSelection)[] = [];
|
||||
|
||||
if (elements.length > 0 && this.surfaceModel) {
|
||||
instances.push(
|
||||
this.stdSelection.create(
|
||||
SurfaceSelection,
|
||||
this.surfaceModel.id,
|
||||
elements,
|
||||
selection.editing ?? false,
|
||||
selection.inoperable
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (blocks.length > 0) {
|
||||
instances = instances.concat(
|
||||
blocks.map(blockId =>
|
||||
this.stdSelection.create(
|
||||
SurfaceSelection,
|
||||
blockId,
|
||||
[blockId],
|
||||
selection.editing ?? false,
|
||||
selection.inoperable
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.stdSelection.setGroup(
|
||||
'gfx',
|
||||
this.cursorSelection
|
||||
? instances.concat([this.cursorSelection])
|
||||
: instances
|
||||
);
|
||||
|
||||
if (instances.length > 0) {
|
||||
this.stdSelection.setGroup('note', []);
|
||||
}
|
||||
|
||||
if (
|
||||
selection.elements.length === 1 &&
|
||||
this.firstElement instanceof GfxGroupLikeElementModel
|
||||
) {
|
||||
this._activeGroup = this.firstElement;
|
||||
} else {
|
||||
if (
|
||||
this.selectedElements.some(ele => ele.group !== this._activeGroup) ||
|
||||
this.selectedElements.length === 0
|
||||
) {
|
||||
this._activeGroup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the selection state of single element
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
toggle(element: GfxModel | string) {
|
||||
element = typeof element === 'string' ? element : element.id;
|
||||
|
||||
this.set({
|
||||
elements: this.has(element)
|
||||
? this.selectedIds.filter(id => id !== element)
|
||||
: [...this.selectedIds, element],
|
||||
});
|
||||
}
|
||||
|
||||
setCursor(cursor: CursorSelection | IPoint) {
|
||||
const instance = this.stdSelection.create(
|
||||
CursorSelection,
|
||||
cursor.x,
|
||||
cursor.y
|
||||
);
|
||||
|
||||
this.stdSelection.setGroup('gfx', [...this.surfaceSelections, instance]);
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
declare module './controller.js' {
|
||||
interface GfxController {
|
||||
readonly selection: GfxSelectionManager;
|
||||
}
|
||||
}
|
||||
61
blocksuite/framework/std/src/gfx/surface-middleware.ts
Normal file
61
blocksuite/framework/std/src/gfx/surface-middleware.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js';
|
||||
import { StdIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
import { onSurfaceAdded } from '../utils/gfx.js';
|
||||
import { GfxControllerIdentifier } from './identifiers.js';
|
||||
import type { SurfaceMiddleware } from './model/surface/surface-model.js';
|
||||
|
||||
export abstract class SurfaceMiddlewareBuilder extends Extension {
|
||||
static key: string = '';
|
||||
|
||||
abstract middleware: SurfaceMiddleware;
|
||||
|
||||
get gfx() {
|
||||
return this.std.provider.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
constructor(protected std: BlockStdScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'The surface middleware builder should have a static key property.'
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(SurfaceMiddlewareBuilderIdentifier(this.key), this, [
|
||||
StdIdentifier,
|
||||
]);
|
||||
}
|
||||
|
||||
mounted(): void {}
|
||||
|
||||
unmounted(): void {}
|
||||
}
|
||||
|
||||
export const SurfaceMiddlewareBuilderIdentifier =
|
||||
createIdentifier<SurfaceMiddlewareBuilder>('SurfaceMiddlewareBuilder');
|
||||
|
||||
export class SurfaceMiddlewareExtension extends LifeCycleWatcher {
|
||||
static override key: string = 'surfaceMiddleware';
|
||||
|
||||
override mounted(): void {
|
||||
const builders = Array.from(
|
||||
this.std.provider.getAll(SurfaceMiddlewareBuilderIdentifier).values()
|
||||
);
|
||||
|
||||
const dispose = onSurfaceAdded(this.std.store, surface => {
|
||||
if (surface) {
|
||||
surface.applyMiddlewares(builders.map(builder => builder.middleware));
|
||||
queueMicrotask(() => dispose());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
635
blocksuite/framework/std/src/gfx/tool/tool-controller.ts
Normal file
635
blocksuite/framework/std/src/gfx/tool/tool-controller.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { IBound, IPoint } from '@blocksuite/global/gfx';
|
||||
import { computed, Signal } from '@preact/signals-core';
|
||||
import { Subject, type Subscription } from 'rxjs';
|
||||
|
||||
import type { PointerEventState } from '../../event/index.js';
|
||||
import type { GfxController } from '../controller.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
||||
import {
|
||||
type BaseTool,
|
||||
ToolIdentifier,
|
||||
type ToolOptions,
|
||||
type ToolOptionWithType,
|
||||
type ToolType,
|
||||
} from './tool.js';
|
||||
|
||||
type BuiltInHookEvent<T> = {
|
||||
data: T;
|
||||
preventDefault(): void;
|
||||
};
|
||||
|
||||
type BuiltInEventMap = {
|
||||
beforeToolUpdate: BuiltInHookEvent<{
|
||||
toolName: string;
|
||||
}>;
|
||||
toolUpdate: BuiltInHookEvent<{ toolName: string }>;
|
||||
};
|
||||
|
||||
type BuiltInSlotContext = {
|
||||
[K in keyof BuiltInEventMap]: { event: K } & BuiltInEventMap[K];
|
||||
}[SupportedHooks];
|
||||
|
||||
export type SupportedHooks = keyof BuiltInEventMap;
|
||||
|
||||
const supportedEvents = [
|
||||
'dragStart',
|
||||
'dragEnd',
|
||||
'dragMove',
|
||||
'pointerMove',
|
||||
'contextMenu',
|
||||
'pointerDown',
|
||||
'pointerUp',
|
||||
'click',
|
||||
'doubleClick',
|
||||
'tripleClick',
|
||||
'pointerOut',
|
||||
] as const;
|
||||
|
||||
export type SupportedEvents = (typeof supportedEvents)[number];
|
||||
|
||||
export enum MouseButton {
|
||||
FIFTH = 4,
|
||||
FOURTH = 3,
|
||||
MAIN = 0,
|
||||
MIDDLE = 1,
|
||||
SECONDARY = 2,
|
||||
}
|
||||
|
||||
export interface ToolEventTarget {
|
||||
/**
|
||||
* Add a hook before the event is handled by the tool.
|
||||
* Return false to prevent the tool from handling the event.
|
||||
* @param evtName
|
||||
* @param handler
|
||||
*/
|
||||
addHook<K extends SupportedHooks | SupportedEvents>(
|
||||
evtName: K,
|
||||
handler: (
|
||||
evtState: K extends SupportedHooks
|
||||
? BuiltInEventMap[K]
|
||||
: PointerEventState
|
||||
) => void | boolean
|
||||
): void;
|
||||
}
|
||||
|
||||
type AreaBound = IBound & {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
};
|
||||
|
||||
export class ToolController extends GfxExtension {
|
||||
static override key = 'ToolController';
|
||||
|
||||
private readonly _builtInHookSlot = new Subject<BuiltInSlotContext>();
|
||||
|
||||
private readonly _disposableGroup = new DisposableGroup();
|
||||
|
||||
private readonly _toolOption$ = new Signal<ToolOptionWithType>({});
|
||||
|
||||
private readonly _tools = new Map<string, BaseTool>();
|
||||
|
||||
readonly currentToolName$ = new Signal<string>();
|
||||
|
||||
readonly dragging$ = new Signal<boolean>(false);
|
||||
|
||||
/**
|
||||
* The dragging area in browser coordinates space.
|
||||
*
|
||||
* This is similar to `draggingViewArea$`, but if the viewport is changed during dragging,
|
||||
* it will be reflected in this area.
|
||||
*/
|
||||
readonly draggingViewportArea$ = computed(() => {
|
||||
const compute = (modelArea: AreaBound) => {
|
||||
const [viewStartX, viewStartY] = this.gfx.viewport.toViewCoord(
|
||||
modelArea.x,
|
||||
modelArea.y
|
||||
);
|
||||
const [viewEndX, viewEndY] = this.gfx.viewport.toViewCoord(
|
||||
modelArea.x + modelArea.w,
|
||||
modelArea.y + modelArea.h
|
||||
);
|
||||
|
||||
return {
|
||||
startX: viewStartX,
|
||||
startY: viewStartY,
|
||||
endX: viewEndX,
|
||||
endY: viewEndY,
|
||||
x: Math.min(viewStartX, viewEndX),
|
||||
y: Math.min(viewStartY, viewEndY),
|
||||
w: Math.abs(viewStartX - viewEndX),
|
||||
h: Math.abs(viewStartY - viewEndY),
|
||||
};
|
||||
};
|
||||
|
||||
return compute(this.draggingArea$.value);
|
||||
});
|
||||
|
||||
/**
|
||||
* The dragging area in browser coordinates space.
|
||||
*/
|
||||
readonly draggingViewArea$ = new Signal<
|
||||
IBound & {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
}
|
||||
>({
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
endX: 0,
|
||||
endY: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* The last mouse move position in browser coordinates space.
|
||||
*/
|
||||
readonly lastMouseViewPos$ = new Signal<IPoint>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* The last mouse position in model coordinates space.
|
||||
*/
|
||||
readonly lastMousePos$ = computed(() => {
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(
|
||||
this.lastMouseViewPos$.value.x,
|
||||
this.lastMouseViewPos$.value.y
|
||||
);
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
});
|
||||
|
||||
get currentTool$() {
|
||||
// oxlint-disable-next-line typescript/no-this-alias
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return self._tools.get(self.currentToolName$.value);
|
||||
},
|
||||
peek() {
|
||||
return self._tools.get(self.currentToolName$.peek());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get currentToolOption$() {
|
||||
// oxlint-disable-next-line typescript/no-this-alias
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
peek() {
|
||||
return self._toolOption$.peek();
|
||||
},
|
||||
|
||||
get value() {
|
||||
return self._toolOption$.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The area that is being dragged.
|
||||
* The coordinates are in model space.
|
||||
*/
|
||||
readonly draggingArea$ = new Signal<
|
||||
IBound & {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
}
|
||||
>({
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
endX: 0,
|
||||
endY: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
});
|
||||
|
||||
static override extendGfx(gfx: GfxController) {
|
||||
Object.defineProperty(gfx, 'tool', {
|
||||
get() {
|
||||
return this.std.provider.get(ToolControllerIdentifier);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _createBuiltInHookCtx<K extends keyof BuiltInEventMap>(
|
||||
eventName: K,
|
||||
data: BuiltInEventMap[K]['data']
|
||||
): {
|
||||
prevented: boolean;
|
||||
slotCtx: BuiltInSlotContext;
|
||||
} {
|
||||
const ctx = {
|
||||
prevented: false,
|
||||
slotCtx: {
|
||||
event: eventName,
|
||||
data,
|
||||
preventDefault() {
|
||||
ctx.prevented = true;
|
||||
},
|
||||
} as BuiltInSlotContext,
|
||||
};
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private _initializeEvents() {
|
||||
const hooks: Record<
|
||||
string,
|
||||
((
|
||||
evtState: PointerEventState | BuiltInSlotContext
|
||||
) => undefined | boolean)[]
|
||||
> = {};
|
||||
/**
|
||||
* Invoke the hook and the tool handler.
|
||||
* @returns false if the handler is prevented by the hook
|
||||
*/
|
||||
const invokeToolHandler = (
|
||||
evtName: SupportedEvents,
|
||||
evt: PointerEventState,
|
||||
tool?: BaseTool
|
||||
) => {
|
||||
const evtHooks = hooks[evtName];
|
||||
const stopHandler = evtHooks?.reduce((pre, hook) => {
|
||||
return pre || hook(evt) === false;
|
||||
}, false);
|
||||
|
||||
tool = tool ?? this.currentTool$.peek();
|
||||
|
||||
if (stopHandler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
tool?.[evtName](evt);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ExecutionError,
|
||||
`Error occurred while executing ${evtName} handler of tool "${tool?.toolName}"`,
|
||||
{
|
||||
cause: e as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook into the event lifecycle.
|
||||
* All hooks will be executed despite the current active tool.
|
||||
* This is useful for tools that need to perform some action before an event is handled.
|
||||
* @param evtName
|
||||
* @param handler
|
||||
*/
|
||||
const addHook: ToolEventTarget['addHook'] = (evtName, handler) => {
|
||||
hooks[evtName] = hooks[evtName] ?? [];
|
||||
hooks[evtName].push(
|
||||
handler as (
|
||||
evtState: PointerEventState | BuiltInSlotContext
|
||||
) => undefined | boolean
|
||||
);
|
||||
|
||||
return () => {
|
||||
const idx = hooks[evtName].indexOf(
|
||||
handler as (
|
||||
evtState: PointerEventState | BuiltInSlotContext
|
||||
) => undefined | boolean
|
||||
);
|
||||
if (idx !== -1) {
|
||||
hooks[evtName].splice(idx, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let dragContext: {
|
||||
tool: BaseTool;
|
||||
} | null = null;
|
||||
let viewportSub: Subscription | null = null;
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('dragStart', ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
if (
|
||||
evt.button === MouseButton.SECONDARY &&
|
||||
!this.currentTool$.peek()?.allowDragWithRightButton
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.button === MouseButton.MIDDLE) {
|
||||
evt.raw.preventDefault();
|
||||
}
|
||||
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
|
||||
|
||||
this.dragging$.value = true;
|
||||
this.draggingViewArea$.value = {
|
||||
startX: evt.x,
|
||||
startY: evt.y,
|
||||
endX: evt.x,
|
||||
endY: evt.y,
|
||||
x: evt.x,
|
||||
y: evt.y,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
this.draggingArea$.value = {
|
||||
startX: modelX,
|
||||
startY: modelY,
|
||||
endX: modelX,
|
||||
endY: modelY,
|
||||
x: modelX,
|
||||
y: modelY,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
this.lastMouseViewPos$.value = {
|
||||
x: evt.x,
|
||||
y: evt.y,
|
||||
};
|
||||
|
||||
viewportSub?.unsubscribe();
|
||||
viewportSub = this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
const lastPost = this.lastMouseViewPos$.peek();
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(
|
||||
lastPost.x,
|
||||
lastPost.y
|
||||
);
|
||||
|
||||
const original = this.draggingArea$.peek();
|
||||
|
||||
this.draggingArea$.value = {
|
||||
...this.draggingArea$.peek(),
|
||||
x: Math.min(modelX, original.startX),
|
||||
y: Math.min(modelY, original.startY),
|
||||
w: Math.abs(modelX - original.startX),
|
||||
h: Math.abs(modelY - original.startY),
|
||||
endX: modelX,
|
||||
endY: modelY,
|
||||
};
|
||||
});
|
||||
|
||||
// this means the dragEnd event is not even fired
|
||||
// so we need to manually call the dragEnd method
|
||||
if (dragContext?.tool) {
|
||||
dragContext.tool.dragEnd(evt);
|
||||
dragContext = null;
|
||||
}
|
||||
|
||||
if (invokeToolHandler('dragStart', evt)) {
|
||||
dragContext = this.currentTool$.peek()
|
||||
? {
|
||||
tool: this.currentTool$.peek()!,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('dragMove', ctx => {
|
||||
if (!this.dragging$.peek()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const evt = ctx.get('pointerState');
|
||||
const draggingStart = {
|
||||
x: this.draggingArea$.peek().startX,
|
||||
y: this.draggingArea$.peek().startY,
|
||||
originX: this.draggingViewArea$.peek().startX,
|
||||
originY: this.draggingViewArea$.peek().startY,
|
||||
};
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
|
||||
|
||||
this.draggingViewArea$.value = {
|
||||
...this.draggingViewArea$.peek(),
|
||||
w: Math.abs(evt.x - draggingStart.originX),
|
||||
h: Math.abs(evt.y - draggingStart.originY),
|
||||
x: Math.min(evt.x, draggingStart.originX),
|
||||
y: Math.min(evt.y, draggingStart.originY),
|
||||
endX: evt.x,
|
||||
endY: evt.y,
|
||||
};
|
||||
|
||||
this.draggingArea$.value = {
|
||||
...this.draggingArea$.peek(),
|
||||
w: Math.abs(modelX - draggingStart.x),
|
||||
h: Math.abs(modelY - draggingStart.y),
|
||||
x: Math.min(modelX, draggingStart.x),
|
||||
y: Math.min(modelY, draggingStart.y),
|
||||
endX: modelX,
|
||||
endY: modelY,
|
||||
};
|
||||
|
||||
this.lastMouseViewPos$.value = {
|
||||
x: evt.x,
|
||||
y: evt.y,
|
||||
};
|
||||
|
||||
invokeToolHandler('dragMove', evt, dragContext?.tool);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('dragEnd', ctx => {
|
||||
if (!this.dragging$.peek()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dragging$.value = false;
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
// if the tool dragEnd is prevented by the hook, call the dragEnd method manually
|
||||
// this guarantee the dragStart and dragEnd events are always called together
|
||||
if (
|
||||
!invokeToolHandler('dragEnd', evt, dragContext?.tool) &&
|
||||
dragContext?.tool
|
||||
) {
|
||||
dragContext.tool.dragEnd(evt);
|
||||
}
|
||||
|
||||
viewportSub?.unsubscribe();
|
||||
viewportSub = null;
|
||||
dragContext = null;
|
||||
this.draggingViewArea$.value = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
endX: 0,
|
||||
endY: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
this.draggingArea$.value = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
endX: 0,
|
||||
endY: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('pointerMove', ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
this.lastMouseViewPos$.value = {
|
||||
x: evt.x,
|
||||
y: evt.y,
|
||||
};
|
||||
|
||||
invokeToolHandler('pointerMove', evt);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('contextMenu', ctx => {
|
||||
const evt = ctx.get('defaultState');
|
||||
|
||||
// when in editing mode, allow context menu to pop up
|
||||
if (this.gfx.selection.editing) return;
|
||||
|
||||
evt.event.preventDefault();
|
||||
})
|
||||
);
|
||||
|
||||
supportedEvents.slice(5).forEach(evtName => {
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add(evtName, ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
invokeToolHandler(evtName, evt);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
this._builtInHookSlot.subscribe(evt => {
|
||||
hooks[evt.event]?.forEach(hook => hook(evt));
|
||||
});
|
||||
|
||||
return {
|
||||
addHook,
|
||||
};
|
||||
}
|
||||
|
||||
private _register(tools: BaseTool) {
|
||||
if (this._tools.has(tools.toolName)) {
|
||||
this._tools.get(tools.toolName)?.unmounted();
|
||||
}
|
||||
|
||||
this._tools.set(tools.toolName, tools);
|
||||
tools.mounted();
|
||||
}
|
||||
|
||||
get = <T extends BaseTool>(type: ToolType<T>): T => {
|
||||
const instance = this._tools.get(type.toolName) as T | undefined;
|
||||
if (!instance) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
`Trying to get tool "${type.toolName}" is not registered`
|
||||
);
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
override mounted(): void {
|
||||
const { addHook } = this._initializeEvents();
|
||||
|
||||
const eventTarget: ToolEventTarget = {
|
||||
addHook,
|
||||
};
|
||||
|
||||
this.std.provider.getAll(ToolIdentifier).forEach(tool => {
|
||||
// @ts-expect-error ignore
|
||||
tool['eventTarget'] = eventTarget;
|
||||
this._register(tool);
|
||||
});
|
||||
}
|
||||
|
||||
setTool = <T extends BaseTool>(
|
||||
toolType: ToolType<T>,
|
||||
options?: ToolOptions<T>
|
||||
): void => {
|
||||
const toolNameStr = toolType.toolName;
|
||||
|
||||
const beforeUpdateCtx = this._createBuiltInHookCtx('beforeToolUpdate', {
|
||||
toolName: toolNameStr,
|
||||
});
|
||||
this._builtInHookSlot.next(beforeUpdateCtx.slotCtx);
|
||||
|
||||
if (beforeUpdateCtx.prevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// explicitly clear the selection when switching tools
|
||||
this.gfx.selection.set({ elements: [] });
|
||||
|
||||
this.currentTool$.peek()?.deactivate();
|
||||
this.currentToolName$.value = toolNameStr;
|
||||
|
||||
const currentTool = this.currentTool$.peek();
|
||||
if (!currentTool) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
`Tool "${this.currentToolName$.value}" is not defined`
|
||||
);
|
||||
}
|
||||
|
||||
currentTool.activatedOption = options ?? {};
|
||||
this._toolOption$.value = {
|
||||
toolType,
|
||||
options: currentTool.activatedOption,
|
||||
};
|
||||
currentTool.activate(currentTool.activatedOption);
|
||||
|
||||
const afterUpdateCtx = this._createBuiltInHookCtx('toolUpdate', {
|
||||
toolName: toolNameStr,
|
||||
});
|
||||
this._builtInHookSlot.next(afterUpdateCtx.slotCtx);
|
||||
};
|
||||
|
||||
override unmounted(): void {
|
||||
this.currentTool$.peek()?.deactivate();
|
||||
this._tools.forEach(tool => {
|
||||
tool.unmounted();
|
||||
tool['disposable'].dispose();
|
||||
});
|
||||
this._builtInHookSlot.complete();
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolControllerIdentifier = GfxExtensionIdentifier(
|
||||
'ToolController'
|
||||
) as ServiceIdentifier<ToolController>;
|
||||
|
||||
declare module '../controller.js' {
|
||||
interface GfxController {
|
||||
readonly tool: ToolController;
|
||||
}
|
||||
}
|
||||
125
blocksuite/framework/std/src/gfx/tool/tool.ts
Normal file
125
blocksuite/framework/std/src/gfx/tool/tool.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { PointerEventState } from '../../event/index.js';
|
||||
import type { GfxController } from '../controller.js';
|
||||
import { GfxControllerIdentifier } from '../identifiers.js';
|
||||
import type { ToolEventTarget } from './tool-controller.js';
|
||||
|
||||
export abstract class BaseTool<
|
||||
Option = Record<string, unknown>,
|
||||
> extends Extension {
|
||||
static toolName: string = '';
|
||||
|
||||
private readonly eventTarget!: ToolEventTarget;
|
||||
|
||||
activatedOption: Option = {} as Option;
|
||||
|
||||
addHook: ToolEventTarget['addHook'] = (evtName, handler) => {
|
||||
this.eventTarget.addHook(evtName, handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* The `disposable` will be disposed when the tool is unloaded.
|
||||
*/
|
||||
protected readonly disposable = new DisposableGroup();
|
||||
|
||||
get active() {
|
||||
return this.gfx.tool.currentTool$.peek() === this;
|
||||
}
|
||||
|
||||
get allowDragWithRightButton() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get controller() {
|
||||
return this.gfx.tool;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.gfx.doc;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
get toolName() {
|
||||
return (this.constructor as typeof BaseTool).toolName;
|
||||
}
|
||||
|
||||
constructor(readonly gfx: GfxController) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container): void {
|
||||
if (!this.toolName) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
`The tool constructor '${this.name}' should have a static 'toolName' property.`
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(ToolIdentifier(this.toolName), this, [GfxControllerIdentifier]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the tool is activated.
|
||||
* @param _ - The data passed as second argument when calling `ToolController.use`.
|
||||
*/
|
||||
activate(_: Option): void {}
|
||||
|
||||
click(_: PointerEventState): void {}
|
||||
|
||||
contextMenu(_: PointerEventState): void {}
|
||||
|
||||
/**
|
||||
* Called when the tool is deactivated.
|
||||
*/
|
||||
deactivate(): void {}
|
||||
|
||||
doubleClick(_: PointerEventState): void {}
|
||||
|
||||
dragEnd(_: PointerEventState): void {}
|
||||
|
||||
dragMove(_: PointerEventState): void {}
|
||||
|
||||
dragStart(_: PointerEventState): void {}
|
||||
|
||||
/**
|
||||
* Called when the tool is registered.
|
||||
*/
|
||||
mounted(): void {}
|
||||
|
||||
pointerDown(_: PointerEventState): void {}
|
||||
|
||||
pointerMove(_: PointerEventState): void {}
|
||||
|
||||
pointerOut(_: PointerEventState): void {}
|
||||
|
||||
pointerUp(_: PointerEventState): void {}
|
||||
|
||||
tripleClick(_: PointerEventState): void {}
|
||||
|
||||
/**
|
||||
* Called when the tool is unloaded, usually when the whole `ToolController` is destroyed.
|
||||
*/
|
||||
unmounted(): void {}
|
||||
}
|
||||
|
||||
export const ToolIdentifier = createIdentifier<BaseTool>('GfxTool');
|
||||
|
||||
export type ToolType<T extends BaseTool = BaseTool> = {
|
||||
new (gfx: GfxController): T;
|
||||
toolName: string;
|
||||
};
|
||||
|
||||
export type ToolOptions<T extends BaseTool> =
|
||||
T extends BaseTool<infer O> ? O : never;
|
||||
|
||||
export type ToolOptionWithType<T extends BaseTool = BaseTool> = {
|
||||
toolType?: ToolType<T>;
|
||||
options?: ToolOptions<T>;
|
||||
};
|
||||
139
blocksuite/framework/std/src/gfx/view/view-manager.ts
Normal file
139
blocksuite/framework/std/src/gfx/view/view-manager.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
|
||||
import { onSurfaceAdded } from '../../utils/gfx.js';
|
||||
import {
|
||||
type GfxBlockComponent,
|
||||
isGfxBlockComponent,
|
||||
} from '../../view/index.js';
|
||||
import type { GfxController } from '../controller.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
||||
import type { GfxModel } from '../model/model.js';
|
||||
import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
|
||||
import type { SurfaceBlockModel } from '../model/surface/surface-model.js';
|
||||
import {
|
||||
GfxElementModelView,
|
||||
GfxElementModelViewExtIdentifier,
|
||||
} from './view.js';
|
||||
|
||||
export class ViewManager extends GfxExtension {
|
||||
static override key = 'viewManager';
|
||||
|
||||
private readonly _disposable = new DisposableGroup();
|
||||
|
||||
private readonly _viewCtorMap = new Map<string, typeof GfxElementModelView>();
|
||||
|
||||
private readonly _viewMap = new Map<string, GfxElementModelView>();
|
||||
|
||||
constructor(gfx: GfxController) {
|
||||
super(gfx);
|
||||
}
|
||||
|
||||
static override extendGfx(gfx: GfxController): void {
|
||||
Object.defineProperty(gfx, 'view', {
|
||||
get() {
|
||||
return this.std.get(GfxExtensionIdentifier('viewManager'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get(
|
||||
model: GfxModel | GfxLocalElementModel | string
|
||||
): GfxElementModelView | GfxBlockComponent | null {
|
||||
model = typeof model === 'string' ? model : model.id;
|
||||
|
||||
if (this._viewMap.has(model)) {
|
||||
return this._viewMap.get(model)!;
|
||||
}
|
||||
|
||||
const blockView = this.std.view.getBlock(model);
|
||||
|
||||
if (blockView && isGfxBlockComponent(blockView)) {
|
||||
return blockView;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
this.std.provider
|
||||
.getAll(GfxElementModelViewExtIdentifier)
|
||||
.forEach(viewCtor => {
|
||||
this._viewCtorMap.set(viewCtor.type, viewCtor);
|
||||
});
|
||||
|
||||
const updateViewOnElementChange = (surface: SurfaceBlockModel) => {
|
||||
const createView = (
|
||||
model: GfxPrimitiveElementModel | GfxLocalElementModel
|
||||
) => {
|
||||
const ViewCtor =
|
||||
this._viewCtorMap.get(model.type) ?? GfxElementModelView;
|
||||
const view = new ViewCtor(model, this.gfx);
|
||||
|
||||
this._viewMap.set(model.id, view);
|
||||
view.onCreated();
|
||||
};
|
||||
|
||||
this._disposable.add(
|
||||
surface.elementAdded.subscribe(payload => {
|
||||
const model = surface.getElementById(payload.id)!;
|
||||
createView(model);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
surface.elementRemoved.subscribe(elem => {
|
||||
const view = this._viewMap.get(elem.id);
|
||||
this._viewMap.delete(elem.id);
|
||||
view?.onDestroyed();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
surface.localElementAdded.subscribe(model => {
|
||||
createView(model);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
surface.localElementDeleted.subscribe(model => {
|
||||
const view = this._viewMap.get(model.id);
|
||||
this._viewMap.delete(model.id);
|
||||
view?.onDestroyed();
|
||||
})
|
||||
);
|
||||
|
||||
surface.localElementModels.forEach(model => {
|
||||
createView(model);
|
||||
});
|
||||
|
||||
surface.elementModels.forEach(model => {
|
||||
createView(model);
|
||||
});
|
||||
};
|
||||
|
||||
if (this.gfx.surface) {
|
||||
updateViewOnElementChange(this.gfx.surface);
|
||||
} else {
|
||||
this._disposable.add(
|
||||
onSurfaceAdded(this.std.store, surface => {
|
||||
if (surface) {
|
||||
updateViewOnElementChange(surface);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override unmounted(): void {
|
||||
this._disposable.dispose();
|
||||
this._viewMap.forEach(view => view.onDestroyed());
|
||||
this._viewMap.clear();
|
||||
}
|
||||
}
|
||||
|
||||
declare module '../controller.js' {
|
||||
interface GfxController {
|
||||
readonly view: ViewManager;
|
||||
}
|
||||
}
|
||||
225
blocksuite/framework/std/src/gfx/view/view.ts
Normal file
225
blocksuite/framework/std/src/gfx/view/view.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { type Bound, type IVec } from '@blocksuite/global/gfx';
|
||||
import type { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { PointerEventState } from '../../event/index.js';
|
||||
import type { EditorHost } from '../../view/index.js';
|
||||
import type { GfxController } from '../index.js';
|
||||
import type {
|
||||
BoxSelectionContext,
|
||||
DragEndContext,
|
||||
DragMoveContext,
|
||||
DragStartContext,
|
||||
GfxViewTransformInterface,
|
||||
} from '../interactivity/index.js';
|
||||
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
|
||||
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
|
||||
|
||||
export type EventsHandlerMap = {
|
||||
click: PointerEventState;
|
||||
dblclick: PointerEventState;
|
||||
pointerdown: PointerEventState;
|
||||
pointerenter: PointerEventState;
|
||||
pointerleave: PointerEventState;
|
||||
pointermove: PointerEventState;
|
||||
pointerup: PointerEventState;
|
||||
dragstart: PointerEventState;
|
||||
dragmove: PointerEventState;
|
||||
dragend: PointerEventState;
|
||||
};
|
||||
|
||||
export type SupportedEvent = keyof EventsHandlerMap;
|
||||
|
||||
export const GfxElementModelViewExtIdentifier = createIdentifier<
|
||||
typeof GfxElementModelView
|
||||
>('GfxElementModelView');
|
||||
|
||||
export class GfxElementModelView<
|
||||
T extends GfxLocalElementModel | GfxPrimitiveElementModel =
|
||||
| GfxPrimitiveElementModel
|
||||
| GfxLocalElementModel,
|
||||
RendererContext = object,
|
||||
>
|
||||
implements GfxElementGeometry, Extension, GfxViewTransformInterface
|
||||
{
|
||||
static type: string;
|
||||
|
||||
private readonly _handlers = new Map<
|
||||
keyof EventsHandlerMap,
|
||||
((evt: any) => void)[]
|
||||
>();
|
||||
|
||||
private _isConnected = true;
|
||||
|
||||
protected disposable = new DisposableGroup();
|
||||
|
||||
readonly model: T;
|
||||
|
||||
get isConnected() {
|
||||
return this._isConnected;
|
||||
}
|
||||
|
||||
get rotate() {
|
||||
return this.model.rotate;
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this.model.surface;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.model.type;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
constructor(
|
||||
model: T,
|
||||
readonly gfx: GfxController
|
||||
) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
static setup(di: Container): void {
|
||||
if (!this.type) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'The GfxElementModelView should have a static `type` property.'
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(GfxElementModelViewExtIdentifier(this.type), () => this);
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
return this.model.containsBound(bounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an event to the view.
|
||||
* @param event
|
||||
* @param evt
|
||||
* @returns Whether the event view has any handlers for the event.
|
||||
*/
|
||||
dispatch<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
evt: EventsHandlerMap[K]
|
||||
) {
|
||||
const handlers = this._handlers.get(event);
|
||||
|
||||
if (handlers?.length) {
|
||||
handlers.forEach(callback => callback(evt));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec) {
|
||||
return this.model.getLineIntersections(start, end);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec) {
|
||||
return this.model.getNearestPoint(point);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec) {
|
||||
return this.model.getRelativePointLocation(relativePoint);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
_: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
return this.model.includesPoint(x, y, _, __);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
off<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
callback: (evt: EventsHandlerMap[K]) => void
|
||||
) {
|
||||
if (!this._handlers.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = this._handlers.get(event)!;
|
||||
const index = callbacks.indexOf(callback);
|
||||
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
on<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
callback: (evt: EventsHandlerMap[K]) => void
|
||||
) {
|
||||
if (!this._handlers.has(event)) {
|
||||
this._handlers.set(event, []);
|
||||
}
|
||||
|
||||
this._handlers.get(event)!.push(callback);
|
||||
|
||||
return () => this.off(event, callback);
|
||||
}
|
||||
|
||||
once<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
callback: (evt: EventsHandlerMap[K]) => void
|
||||
) {
|
||||
const off = this.on(event, evt => {
|
||||
off();
|
||||
callback(evt);
|
||||
});
|
||||
|
||||
return off;
|
||||
}
|
||||
|
||||
onCreated() {}
|
||||
|
||||
onDragStart(_: DragStartContext) {
|
||||
if (this.model instanceof GfxPrimitiveElementModel) {
|
||||
this.model.stash('xywh');
|
||||
}
|
||||
}
|
||||
|
||||
onDragEnd(_: DragEndContext) {
|
||||
if (this.model instanceof GfxPrimitiveElementModel) {
|
||||
this.model.pop('xywh');
|
||||
}
|
||||
}
|
||||
|
||||
onDragMove({ dx, dy, currentBound }: DragMoveContext) {
|
||||
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
|
||||
}
|
||||
|
||||
onBoxSelected(_: BoxSelectionContext): boolean | void {}
|
||||
|
||||
/**
|
||||
* Called when the view is destroyed.
|
||||
* Override this method requires calling `super.onDestroyed()`.
|
||||
*/
|
||||
onDestroyed() {
|
||||
this._isConnected = false;
|
||||
this.disposable.dispose();
|
||||
this._handlers.clear();
|
||||
}
|
||||
|
||||
render(_: RendererContext) {}
|
||||
}
|
||||
1031
blocksuite/framework/std/src/gfx/viewport-element.ts
Normal file
1031
blocksuite/framework/std/src/gfx/viewport-element.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user