snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (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 4s
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 / 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 / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (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 / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
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 / 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 / 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 @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 / 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 (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (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 (5, 5) (push) Has been skipped
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 / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (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 (x86_64-apple-darwin) (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
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (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 4s
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 / 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 / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (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 / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
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 / 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 / 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 @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 / 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 (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (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 (5, 5) (push) Has been skipped
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 / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (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 (x86_64-apple-darwin) (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
This commit is contained in:
3
blocksuite/framework/README.md
Normal file
3
blocksuite/framework/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# BlockSuite Framework
|
||||
|
||||
Here are the vanilla framework packages in BlockSuite.
|
||||
67
blocksuite/framework/global/package.json
Normal file
67
blocksuite/framework/global/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@blocksuite/global",
|
||||
"types": "./index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./utils": "./src/utils/index.ts",
|
||||
"./env": "./src/env/index.ts",
|
||||
"./exceptions": "./src/exceptions/index.ts",
|
||||
"./di": "./src/di/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./gfx": "./src/gfx/index.ts",
|
||||
"./disposable": "./src/disposable/index.ts",
|
||||
"./lit": "./src/lit/index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"utils": [
|
||||
"dist/utils/index.d.ts"
|
||||
],
|
||||
"env": [
|
||||
"dist/env/index.d.ts"
|
||||
],
|
||||
"exceptions": [
|
||||
"dist/exceptions/index.d.ts"
|
||||
],
|
||||
"di": [
|
||||
"dist/di/index.d.ts"
|
||||
],
|
||||
"types": [
|
||||
"dist/types/index.d.ts"
|
||||
],
|
||||
"gfx": [
|
||||
"dist/gfx/index.d.ts"
|
||||
],
|
||||
"disposable": [
|
||||
"dist/disposable/index.d.ts"
|
||||
],
|
||||
"lit": [
|
||||
"dist/lit/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"index.d.ts",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lib0": "^0.2.114",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"version": "0.27.5"
|
||||
}
|
||||
22
blocksuite/framework/global/src/__tests__/curve.unit.spec.ts
Normal file
22
blocksuite/framework/global/src/__tests__/curve.unit.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getBezierParameters } from '../gfx/curve.js';
|
||||
import { PointLocation } from '../gfx/model/index.js';
|
||||
|
||||
describe('getBezierParameters', () => {
|
||||
test('should handle empty path', () => {
|
||||
expect(() => getBezierParameters([])).not.toThrow();
|
||||
expect(getBezierParameters([])).toEqual([
|
||||
new PointLocation(),
|
||||
new PointLocation(),
|
||||
new PointLocation(),
|
||||
new PointLocation(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle single-point path', () => {
|
||||
const point = new PointLocation([10, 20]);
|
||||
|
||||
expect(getBezierParameters([point])).toEqual([point, point, point, point]);
|
||||
});
|
||||
});
|
||||
362
blocksuite/framework/global/src/__tests__/di.unit.spec.ts
Normal file
362
blocksuite/framework/global/src/__tests__/di.unit.spec.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
CircularDependencyError,
|
||||
Container,
|
||||
createIdentifier,
|
||||
createScope,
|
||||
DuplicateServiceDefinitionError,
|
||||
MissingDependencyError,
|
||||
RecursionLimitError,
|
||||
ServiceNotFoundError,
|
||||
ServiceProvider,
|
||||
} from '../di/index.js';
|
||||
|
||||
describe('di', () => {
|
||||
test('basic', () => {
|
||||
const container = new Container();
|
||||
class TestService {
|
||||
a = 'b';
|
||||
}
|
||||
|
||||
container.add(TestService);
|
||||
|
||||
const provider = container.provider();
|
||||
expect(provider.get(TestService)).toEqual({ a: 'b' });
|
||||
});
|
||||
|
||||
test('size', () => {
|
||||
const container = new Container();
|
||||
class TestService {
|
||||
a = 'b';
|
||||
}
|
||||
|
||||
container.add(TestService);
|
||||
|
||||
expect(container.size).toEqual(1);
|
||||
});
|
||||
|
||||
test('dependency', () => {
|
||||
const container = new Container();
|
||||
|
||||
class A {
|
||||
value = 'hello world';
|
||||
}
|
||||
|
||||
class B {
|
||||
constructor(public a: A) {}
|
||||
}
|
||||
|
||||
class C {
|
||||
constructor(public b: B) {}
|
||||
}
|
||||
|
||||
container.add(A).add(B, [A]).add(C, [B]);
|
||||
|
||||
const provider = container.provider();
|
||||
|
||||
expect(provider.get(C).b.a.value).toEqual('hello world');
|
||||
});
|
||||
|
||||
test('identifier', () => {
|
||||
interface Animal {
|
||||
name: string;
|
||||
}
|
||||
const Animal = createIdentifier<Animal>('Animal');
|
||||
|
||||
class Cat {
|
||||
name = 'cat';
|
||||
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
class Zoo {
|
||||
constructor(public animal: Animal) {}
|
||||
}
|
||||
|
||||
const container = new Container();
|
||||
container.addImpl(Animal, Cat).add(Zoo, [Animal]);
|
||||
|
||||
const provider = container.provider();
|
||||
expect(provider.get(Zoo).animal.name).toEqual('cat');
|
||||
});
|
||||
|
||||
test('variant', () => {
|
||||
const container = new Container();
|
||||
|
||||
interface USB {
|
||||
speed: number;
|
||||
}
|
||||
|
||||
const USB = createIdentifier<USB>('USB');
|
||||
|
||||
class TypeA implements USB {
|
||||
speed = 100;
|
||||
}
|
||||
class TypeC implements USB {
|
||||
speed = 300;
|
||||
}
|
||||
|
||||
class PC {
|
||||
constructor(
|
||||
public typeA: USB,
|
||||
public ports: USB[]
|
||||
) {}
|
||||
}
|
||||
|
||||
container
|
||||
.addImpl(USB('A'), TypeA)
|
||||
.addImpl(USB('C'), TypeC)
|
||||
.add(PC, [USB('A'), [USB]]);
|
||||
|
||||
const provider = container.provider();
|
||||
expect(provider.get(USB('A')).speed).toEqual(100);
|
||||
expect(provider.get(USB('C')).speed).toEqual(300);
|
||||
expect(provider.get(PC).typeA.speed).toEqual(100);
|
||||
expect(provider.get(PC).ports.length).toEqual(2);
|
||||
});
|
||||
|
||||
test('lazy initialization', () => {
|
||||
const container = new Container();
|
||||
interface Command {
|
||||
shortcut: string;
|
||||
callback: () => void;
|
||||
}
|
||||
const Command = createIdentifier<Command>('command');
|
||||
|
||||
let pageSystemInitialized = false;
|
||||
|
||||
class PageSystem {
|
||||
mode = 'page';
|
||||
|
||||
name = 'helloworld';
|
||||
|
||||
constructor() {
|
||||
pageSystemInitialized = true;
|
||||
}
|
||||
|
||||
rename() {
|
||||
this.name = 'foobar';
|
||||
}
|
||||
|
||||
switchToEdgeless() {
|
||||
this.mode = 'edgeless';
|
||||
}
|
||||
}
|
||||
|
||||
class CommandSystem {
|
||||
constructor(public commands: Command[]) {}
|
||||
|
||||
execute(shortcut: string) {
|
||||
const command = this.commands.find(c => c.shortcut === shortcut);
|
||||
if (command) {
|
||||
command.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.add(PageSystem);
|
||||
container.add(CommandSystem, [[Command]]);
|
||||
container.addImpl(Command('switch'), p => ({
|
||||
shortcut: 'option+s',
|
||||
callback: () => p.get(PageSystem).switchToEdgeless(),
|
||||
}));
|
||||
container.addImpl(Command('rename'), p => ({
|
||||
shortcut: 'f2',
|
||||
callback: () => p.get(PageSystem).rename(),
|
||||
}));
|
||||
|
||||
const provider = container.provider();
|
||||
const commandSystem = provider.get(CommandSystem);
|
||||
|
||||
expect(
|
||||
pageSystemInitialized,
|
||||
"PageSystem won't be initialized until command executed"
|
||||
).toEqual(false);
|
||||
|
||||
commandSystem.execute('option+s');
|
||||
expect(pageSystemInitialized).toEqual(true);
|
||||
expect(provider.get(PageSystem).mode).toEqual('edgeless');
|
||||
|
||||
expect(provider.get(PageSystem).name).toEqual('helloworld');
|
||||
expect(commandSystem.commands.length).toEqual(2);
|
||||
commandSystem.execute('f2');
|
||||
expect(provider.get(PageSystem).name).toEqual('foobar');
|
||||
});
|
||||
|
||||
test('duplicate, override', () => {
|
||||
const container = new Container();
|
||||
|
||||
const something = createIdentifier<any>('USB');
|
||||
|
||||
class A {
|
||||
a = 'i am A';
|
||||
}
|
||||
|
||||
class B {
|
||||
b = 'i am B';
|
||||
}
|
||||
|
||||
container.addImpl(something, A).override(something, B);
|
||||
|
||||
const provider = container.provider();
|
||||
expect(provider.get(something)).toEqual({ b: 'i am B' });
|
||||
});
|
||||
|
||||
test('scope', () => {
|
||||
const container = new Container();
|
||||
|
||||
const workspaceScope = createScope('workspace');
|
||||
const pageScope = createScope('page', workspaceScope);
|
||||
const editorScope = createScope('editor', pageScope);
|
||||
|
||||
class System {
|
||||
appName = 'affine';
|
||||
}
|
||||
|
||||
container.add(System);
|
||||
|
||||
class Workspace {
|
||||
name = 'workspace';
|
||||
|
||||
constructor(public system: System) {}
|
||||
}
|
||||
|
||||
container.scope(workspaceScope).add(Workspace, [System]);
|
||||
class Page {
|
||||
name = 'page';
|
||||
|
||||
constructor(
|
||||
public system: System,
|
||||
public workspace: Workspace
|
||||
) {}
|
||||
}
|
||||
|
||||
container.scope(pageScope).add(Page, [System, Workspace]);
|
||||
|
||||
class Editor {
|
||||
name = 'editor';
|
||||
|
||||
constructor(public page: Page) {}
|
||||
}
|
||||
|
||||
container.scope(editorScope).add(Editor, [Page]);
|
||||
|
||||
const root = container.provider();
|
||||
expect(root.get(System).appName).toEqual('affine');
|
||||
expect(() => root.get(Workspace)).toThrowError(ServiceNotFoundError);
|
||||
|
||||
const workspace = container.provider(workspaceScope, root);
|
||||
expect(workspace.get(Workspace).name).toEqual('workspace');
|
||||
expect(workspace.get(System).appName).toEqual('affine');
|
||||
expect(() => root.get(Page)).toThrowError(ServiceNotFoundError);
|
||||
|
||||
const page = container.provider(pageScope, workspace);
|
||||
expect(page.get(Page).name).toEqual('page');
|
||||
expect(page.get(Workspace).name).toEqual('workspace');
|
||||
expect(page.get(System).appName).toEqual('affine');
|
||||
|
||||
const editor = container.provider(editorScope, page);
|
||||
expect(editor.get(Editor).name).toEqual('editor');
|
||||
});
|
||||
|
||||
test('service not found', () => {
|
||||
const container = new Container();
|
||||
|
||||
const provider = container.provider();
|
||||
expect(() => provider.get(createIdentifier('SomeService'))).toThrowError(
|
||||
ServiceNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
test('missing dependency', () => {
|
||||
const container = new Container();
|
||||
|
||||
class A {
|
||||
value = 'hello world';
|
||||
}
|
||||
|
||||
class B {
|
||||
constructor(public a: A) {}
|
||||
}
|
||||
|
||||
container.add(B, [A]);
|
||||
|
||||
const provider = container.provider();
|
||||
expect(() => provider.get(B)).toThrowError(MissingDependencyError);
|
||||
});
|
||||
|
||||
test('circular dependency', () => {
|
||||
const container = new Container();
|
||||
|
||||
class A {
|
||||
constructor(public c: C) {}
|
||||
}
|
||||
|
||||
class B {
|
||||
constructor(public a: A) {}
|
||||
}
|
||||
|
||||
class C {
|
||||
constructor(public b: B) {}
|
||||
}
|
||||
|
||||
container.add(A, [C]).add(B, [A]).add(C, [B]);
|
||||
|
||||
const provider = container.provider();
|
||||
expect(() => provider.get(A)).toThrowError(CircularDependencyError);
|
||||
expect(() => provider.get(B)).toThrowError(CircularDependencyError);
|
||||
expect(() => provider.get(C)).toThrowError(CircularDependencyError);
|
||||
});
|
||||
|
||||
test('duplicate service definition', () => {
|
||||
const container = new Container();
|
||||
|
||||
class A {}
|
||||
|
||||
container.add(A);
|
||||
expect(() => container.add(A)).toThrowError(
|
||||
DuplicateServiceDefinitionError
|
||||
);
|
||||
|
||||
class B {}
|
||||
const Something = createIdentifier('something');
|
||||
container.addImpl(Something, A);
|
||||
expect(() => container.addImpl(Something, B)).toThrowError(
|
||||
DuplicateServiceDefinitionError
|
||||
);
|
||||
});
|
||||
|
||||
test('recursion limit', () => {
|
||||
// maxmium resolve depth is 100
|
||||
const container = new Container();
|
||||
const Something = createIdentifier('something');
|
||||
let i = 0;
|
||||
for (; i < 100; i++) {
|
||||
const next = i + 1;
|
||||
|
||||
class Test {
|
||||
constructor(_next: any) {}
|
||||
}
|
||||
|
||||
container.addImpl(Something(i.toString()), Test, [
|
||||
Something(next.toString()),
|
||||
]);
|
||||
}
|
||||
|
||||
class Final {
|
||||
a = 'b';
|
||||
}
|
||||
container.addImpl(Something(i.toString()), Final);
|
||||
const provider = container.provider();
|
||||
expect(() => provider.get(Something('0'))).toThrowError(
|
||||
RecursionLimitError
|
||||
);
|
||||
});
|
||||
|
||||
test('self resolve', () => {
|
||||
const container = new Container();
|
||||
const provider = container.provider();
|
||||
expect(provider.get(ServiceProvider)).toEqual(provider);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { SVGPathBuilder, SVGShapeBuilder } from '../gfx/svg-path.js';
|
||||
|
||||
describe('SVGPathBuilder', () => {
|
||||
test('should build a simple path', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
const result = pathBuilder.moveTo(10, 20).lineTo(30, 40).build();
|
||||
|
||||
expect(result).toBe('M 10 20 L 30 40');
|
||||
});
|
||||
|
||||
test('should build a path with curves', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
const result = pathBuilder
|
||||
.moveTo(0, 0)
|
||||
.curveTo(10, 0, 10, 10, 20, 10)
|
||||
.build();
|
||||
|
||||
expect(result).toBe('M 0 0 C 10 0 10 10 20 10');
|
||||
});
|
||||
|
||||
test('should build a closed path', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
const result = pathBuilder
|
||||
.moveTo(0, 0)
|
||||
.lineTo(10, 0)
|
||||
.lineTo(5, 10)
|
||||
.closePath()
|
||||
.build();
|
||||
|
||||
expect(result).toBe('M 0 0 L 10 0 L 5 10 Z');
|
||||
});
|
||||
|
||||
test('should clear commands', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
pathBuilder.moveTo(10, 20).lineTo(30, 40);
|
||||
pathBuilder.clear();
|
||||
const result = pathBuilder.moveTo(0, 0).build();
|
||||
|
||||
expect(result).toBe('M 0 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVGShapeBuilder', () => {
|
||||
test('should generate diamond polygon points', () => {
|
||||
const result = SVGShapeBuilder.diamond(100, 80, 2);
|
||||
expect(result).toBe('50,1 99,40 50,79 1,40');
|
||||
});
|
||||
|
||||
test('should generate triangle polygon points', () => {
|
||||
const result = SVGShapeBuilder.triangle(100, 80, 2);
|
||||
expect(result).toBe('50,1 99,79 1,79');
|
||||
});
|
||||
|
||||
test('should generate diamond path', () => {
|
||||
const result = SVGShapeBuilder.diamondPath(100, 80, 2);
|
||||
expect(result).toBe('M 50 1 L 99 40 L 50 79 L 1 40 Z');
|
||||
});
|
||||
|
||||
test('should generate triangle path', () => {
|
||||
const result = SVGShapeBuilder.trianglePath(100, 80, 2);
|
||||
expect(result).toBe('M 50 1 L 99 79 L 1 79 Z');
|
||||
});
|
||||
|
||||
test('should handle zero stroke width', () => {
|
||||
const diamondResult = SVGShapeBuilder.diamond(100, 80, 0);
|
||||
expect(diamondResult).toBe('50,0 100,40 50,80 0,40');
|
||||
|
||||
const triangleResult = SVGShapeBuilder.triangle(100, 80, 0);
|
||||
expect(triangleResult).toBe('50,0 100,80 0,80');
|
||||
});
|
||||
});
|
||||
4
blocksuite/framework/global/src/di/consts.ts
Normal file
4
blocksuite/framework/global/src/di/consts.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ServiceVariant } from './types.js';
|
||||
|
||||
export const DEFAULT_SERVICE_VARIANT: ServiceVariant = 'default';
|
||||
export const ROOT_SCOPE = [];
|
||||
459
blocksuite/framework/global/src/di/container.ts
Normal file
459
blocksuite/framework/global/src/di/container.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { DEFAULT_SERVICE_VARIANT, ROOT_SCOPE } from './consts.js';
|
||||
import { DuplicateServiceDefinitionError } from './error.js';
|
||||
import { parseIdentifier } from './identifier.js';
|
||||
import type { ServiceProvider } from './provider.js';
|
||||
import { BasicServiceProvider } from './provider.js';
|
||||
import { stringifyScope } from './scope.js';
|
||||
import type {
|
||||
GeneralServiceIdentifier,
|
||||
ServiceFactory,
|
||||
ServiceIdentifier,
|
||||
ServiceIdentifierType,
|
||||
ServiceIdentifierValue,
|
||||
ServiceScope,
|
||||
ServiceVariant,
|
||||
Type,
|
||||
TypesToDeps,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* A container of services.
|
||||
*
|
||||
* Container basically is a tuple of `[scope, identifier, variant, factory]` with some helper methods.
|
||||
* It just stores the definitions of services. It never holds any instances of services.
|
||||
*
|
||||
* # Usage
|
||||
*
|
||||
* ```ts
|
||||
* const services = new Container();
|
||||
* class ServiceA {
|
||||
* // ...
|
||||
* }
|
||||
* // add a service
|
||||
* services.add(ServiceA);
|
||||
*
|
||||
* class ServiceB {
|
||||
* constructor(serviceA: ServiceA) {}
|
||||
* }
|
||||
* // add a service with dependency
|
||||
* services.add(ServiceB, [ServiceA]);
|
||||
* ^ dependency class/identifier, match ServiceB's constructor
|
||||
*
|
||||
* const FeatureA = createIdentifier<FeatureA>('Config');
|
||||
*
|
||||
* // add a implementation for a service identifier
|
||||
* services.addImpl(FeatureA, ServiceA);
|
||||
*
|
||||
* // override a service
|
||||
* services.override(ServiceA, NewServiceA);
|
||||
*
|
||||
* // create a service provider
|
||||
* const provider = services.provider();
|
||||
* ```
|
||||
*
|
||||
* # The data structure
|
||||
*
|
||||
* The data structure of Container is a three-layer nested Map, used to represent the tuple of
|
||||
* `[scope, identifier, variant, factory]`.
|
||||
* Such a data structure ensures that a service factory can be uniquely determined by `[scope, identifier, variant]`.
|
||||
*
|
||||
* When a service added:
|
||||
*
|
||||
* ```ts
|
||||
* services.add(ServiceClass)
|
||||
* ```
|
||||
*
|
||||
* The data structure will be:
|
||||
*
|
||||
* ```ts
|
||||
* Map {
|
||||
* '': Map { // scope
|
||||
* 'ServiceClass': Map { // identifier
|
||||
* 'default': // variant
|
||||
* () => new ServiceClass() // factory
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* # Dependency relationship
|
||||
*
|
||||
* The dependency relationships of services are not actually stored in the Container,
|
||||
* but are transformed into a factory function when the service is added.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* ```ts
|
||||
* services.add(ServiceB, [ServiceA]);
|
||||
*
|
||||
* // is equivalent to
|
||||
* services.addFactory(ServiceB, (provider) => new ServiceB(provider.get(ServiceA)));
|
||||
* ```
|
||||
*
|
||||
* For multiple implementations of the same service identifier, can be defined as:
|
||||
*
|
||||
* ```ts
|
||||
* services.add(ServiceB, [[FeatureA]]);
|
||||
*
|
||||
* // is equivalent to
|
||||
* services.addFactory(ServiceB, (provider) => new ServiceB(provider.getAll(FeatureA)));
|
||||
* ```
|
||||
*/
|
||||
export class Container {
|
||||
private readonly services = new Map<
|
||||
string,
|
||||
Map<string, Map<ServiceVariant, ServiceFactory>>
|
||||
>();
|
||||
|
||||
/**
|
||||
* @see {@link ContainerEditor.add}
|
||||
*/
|
||||
get add() {
|
||||
return new ContainerEditor(this).add;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link ContainerEditor.addImpl}
|
||||
*/
|
||||
get addImpl() {
|
||||
return new ContainerEditor(this).addImpl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty service container.
|
||||
*
|
||||
* same as `new Container()`
|
||||
*/
|
||||
static get EMPTY() {
|
||||
return new Container();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link ContainerEditor.scope}
|
||||
*/
|
||||
get override() {
|
||||
return new ContainerEditor(this).override;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link ContainerEditor.scope}
|
||||
*/
|
||||
get scope() {
|
||||
return new ContainerEditor(this).scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of services in the container.
|
||||
*/
|
||||
get size() {
|
||||
let size = 0;
|
||||
for (const [, identifiers] of this.services) {
|
||||
for (const [, variants] of identifiers) {
|
||||
size += variants.size;
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Use {@link addImpl} instead.
|
||||
*/
|
||||
addFactory<T>(
|
||||
identifier: GeneralServiceIdentifier<T>,
|
||||
factory: ServiceFactory<T>,
|
||||
{ scope, override }: { scope?: ServiceScope; override?: boolean } = {}
|
||||
) {
|
||||
// convert scope to string
|
||||
const normalizedScope = stringifyScope(scope ?? ROOT_SCOPE);
|
||||
const normalizedIdentifier = parseIdentifier(identifier);
|
||||
const normalizedVariant =
|
||||
normalizedIdentifier.variant ?? DEFAULT_SERVICE_VARIANT;
|
||||
|
||||
const services =
|
||||
this.services.get(normalizedScope) ??
|
||||
new Map<string, Map<ServiceVariant, ServiceFactory>>();
|
||||
|
||||
const variants =
|
||||
services.get(normalizedIdentifier.identifierName) ??
|
||||
new Map<ServiceVariant, ServiceFactory>();
|
||||
|
||||
// throw if service already exists, unless it is an override
|
||||
if (variants.has(normalizedVariant) && !override) {
|
||||
throw new DuplicateServiceDefinitionError(normalizedIdentifier);
|
||||
}
|
||||
variants.set(normalizedVariant, factory);
|
||||
services.set(normalizedIdentifier.identifierName, variants);
|
||||
this.services.set(normalizedScope, services);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Use {@link addImpl} instead.
|
||||
*/
|
||||
addValue<T>(
|
||||
identifier: GeneralServiceIdentifier<T>,
|
||||
value: T,
|
||||
{ scope, override }: { scope?: ServiceScope; override?: boolean } = {}
|
||||
) {
|
||||
this.addFactory(
|
||||
parseIdentifier(identifier) as ServiceIdentifier<T>,
|
||||
() => value,
|
||||
{
|
||||
scope,
|
||||
override,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the entire service container.
|
||||
*
|
||||
* This method is quite cheap as it only clones the references.
|
||||
*
|
||||
* @returns A new service container with the same services.
|
||||
*/
|
||||
clone(): Container {
|
||||
const di = new Container();
|
||||
for (const [scope, identifiers] of this.services) {
|
||||
const s = new Map();
|
||||
for (const [identifier, variants] of identifiers) {
|
||||
s.set(identifier, new Map(variants));
|
||||
}
|
||||
di.services.set(scope, s);
|
||||
}
|
||||
return di;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
getFactory(
|
||||
identifier: ServiceIdentifierValue,
|
||||
scope: ServiceScope = ROOT_SCOPE
|
||||
): ServiceFactory | undefined {
|
||||
return this.services
|
||||
.get(stringifyScope(scope))
|
||||
?.get(identifier.identifierName)
|
||||
?.get(identifier.variant ?? DEFAULT_SERVICE_VARIANT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
getFactoryAll(
|
||||
identifier: ServiceIdentifierValue,
|
||||
scope: ServiceScope = ROOT_SCOPE
|
||||
): Map<ServiceVariant, ServiceFactory> {
|
||||
return new Map(
|
||||
this.services.get(stringifyScope(scope))?.get(identifier.identifierName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a service provider from the container.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* provider() // create a service provider for root scope
|
||||
* provider(ScopeA, parentProvider) // create a service provider for scope A
|
||||
* ```
|
||||
*
|
||||
* @param scope The scope of the service provider, default to the root scope.
|
||||
* @param parent The parent service provider, it is required if the scope is not the root scope.
|
||||
*/
|
||||
provider(
|
||||
scope: ServiceScope = ROOT_SCOPE,
|
||||
parent: ServiceProvider | null = null
|
||||
): ServiceProvider {
|
||||
return new BasicServiceProvider(this, scope, parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper class to edit a service container.
|
||||
*/
|
||||
class ContainerEditor {
|
||||
private currentScope: ServiceScope = ROOT_SCOPE;
|
||||
|
||||
/**
|
||||
* Add a service to the container.
|
||||
*
|
||||
* @see {@link Container}
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* add(ServiceClass, [dependencies, ...])
|
||||
* ```
|
||||
*/
|
||||
add = <
|
||||
T extends new (...args: any) => any,
|
||||
const Deps extends TypesToDeps<ConstructorParameters<T>> = TypesToDeps<
|
||||
ConstructorParameters<T>
|
||||
>,
|
||||
>(
|
||||
cls: T,
|
||||
...[deps]: Deps extends [] ? [] : [Deps]
|
||||
): this => {
|
||||
this.container.addFactory<any>(
|
||||
cls as any,
|
||||
dependenciesToFactory(cls, deps as any),
|
||||
{ scope: this.currentScope }
|
||||
);
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an implementation for identifier to the container.
|
||||
*
|
||||
* @see {@link Container}
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* addImpl(ServiceIdentifier, ServiceClass, [dependencies, ...])
|
||||
* or
|
||||
* addImpl(ServiceIdentifier, Instance)
|
||||
* or
|
||||
* addImpl(ServiceIdentifier, Factory)
|
||||
* ```
|
||||
*/
|
||||
addImpl = <
|
||||
Arg1 extends ServiceIdentifier<any>,
|
||||
Arg2 extends Type<Trait> | ServiceFactory<Trait> | Trait,
|
||||
Trait = ServiceIdentifierType<Arg1>,
|
||||
Deps extends (Arg2 extends Type<Trait>
|
||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
||||
: []) = Arg2 extends Type<Trait>
|
||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
||||
: [],
|
||||
Arg3 extends Deps = Deps,
|
||||
>(
|
||||
identifier: Arg1,
|
||||
arg2: Arg2,
|
||||
...[arg3]: Arg3 extends [] ? [] : [Arg3]
|
||||
): this => {
|
||||
if (arg2 instanceof Function) {
|
||||
this.container.addFactory<any>(
|
||||
identifier,
|
||||
dependenciesToFactory(arg2, arg3 as any[]),
|
||||
{ scope: this.currentScope }
|
||||
);
|
||||
} else {
|
||||
this.container.addValue(identifier, arg2 as any, {
|
||||
scope: this.currentScope,
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* same as {@link addImpl} but this method will override the service if it exists.
|
||||
*
|
||||
* @see {@link Container}
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* override(OriginServiceClass, NewServiceClass, [dependencies, ...])
|
||||
* or
|
||||
* override(ServiceIdentifier, ServiceClass, [dependencies, ...])
|
||||
* or
|
||||
* override(ServiceIdentifier, Instance)
|
||||
* or
|
||||
* override(ServiceIdentifier, Factory)
|
||||
* ```
|
||||
*/
|
||||
override = <
|
||||
Arg1 extends ServiceIdentifier<any>,
|
||||
Arg2 extends Type<Trait> | ServiceFactory<Trait> | Trait,
|
||||
Trait = ServiceIdentifierType<Arg1>,
|
||||
Deps extends (Arg2 extends Type<Trait>
|
||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
||||
: []) = Arg2 extends Type<Trait>
|
||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
||||
: [],
|
||||
Arg3 extends Deps = Deps,
|
||||
>(
|
||||
identifier: Arg1,
|
||||
arg2: Arg2,
|
||||
...[arg3]: Arg3 extends [] ? [] : [Arg3]
|
||||
): this => {
|
||||
if (arg2 instanceof Function) {
|
||||
this.container.addFactory<any>(
|
||||
identifier,
|
||||
dependenciesToFactory(arg2, arg3 as any[]),
|
||||
{ scope: this.currentScope, override: true }
|
||||
);
|
||||
} else {
|
||||
this.container.addValue(identifier, arg2 as any, {
|
||||
scope: this.currentScope,
|
||||
override: true,
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the scope for the service registered subsequently
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const ScopeA = createScope('a');
|
||||
*
|
||||
* services.scope(ScopeA).add(XXXService, ...);
|
||||
* ```
|
||||
*/
|
||||
scope = (scope: ServiceScope): ContainerEditor => {
|
||||
this.currentScope = scope;
|
||||
return this;
|
||||
};
|
||||
|
||||
constructor(private readonly container: Container) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert dependencies definition to a factory function.
|
||||
*/
|
||||
function dependenciesToFactory(
|
||||
cls: any,
|
||||
deps: any[] = []
|
||||
): ServiceFactory<any> {
|
||||
return (provider: ServiceProvider) => {
|
||||
const args = [];
|
||||
for (const dep of deps) {
|
||||
let isAll;
|
||||
let identifier;
|
||||
if (Array.isArray(dep)) {
|
||||
if (dep.length !== 1) {
|
||||
throw new Error('Invalid dependency');
|
||||
}
|
||||
isAll = true;
|
||||
identifier = dep[0];
|
||||
} else {
|
||||
isAll = false;
|
||||
identifier = dep;
|
||||
}
|
||||
if (isAll) {
|
||||
args.push(Array.from(provider.getAll(identifier).values()));
|
||||
} else {
|
||||
args.push(provider.get(identifier));
|
||||
}
|
||||
}
|
||||
if (isConstructor(cls)) {
|
||||
return new cls(...args, provider);
|
||||
} else {
|
||||
return cls(...args, provider);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// a hack to check if a function is a constructor
|
||||
// https://github.com/zloirock/core-js/blob/232c8462c26c75864b4397b7f643a4f57c6981d5/packages/core-js/internals/is-constructor.js#L15
|
||||
function isConstructor(cls: unknown) {
|
||||
try {
|
||||
Reflect.construct(function () {}, [], cls as never);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
59
blocksuite/framework/global/src/di/error.ts
Normal file
59
blocksuite/framework/global/src/di/error.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { DEFAULT_SERVICE_VARIANT } from './consts.js';
|
||||
import type { ServiceIdentifierValue } from './types.js';
|
||||
|
||||
export class RecursionLimitError extends Error {
|
||||
constructor() {
|
||||
super('Dynamic resolve recursion limit reached');
|
||||
}
|
||||
}
|
||||
|
||||
export class CircularDependencyError extends Error {
|
||||
constructor(readonly dependencyStack: ServiceIdentifierValue[]) {
|
||||
super(
|
||||
`A circular dependency was detected.\n` +
|
||||
stringifyDependencyStack(dependencyStack)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceNotFoundError extends Error {
|
||||
constructor(readonly identifier: ServiceIdentifierValue) {
|
||||
super(`Service ${stringifyIdentifier(identifier)} not found in container`);
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingDependencyError extends Error {
|
||||
constructor(
|
||||
readonly from: ServiceIdentifierValue,
|
||||
readonly target: ServiceIdentifierValue,
|
||||
readonly dependencyStack: ServiceIdentifierValue[]
|
||||
) {
|
||||
super(
|
||||
`Missing dependency ${stringifyIdentifier(
|
||||
target
|
||||
)} in creating service ${stringifyIdentifier(
|
||||
from
|
||||
)}.\n${stringifyDependencyStack(dependencyStack)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateServiceDefinitionError extends Error {
|
||||
constructor(readonly identifier: ServiceIdentifierValue) {
|
||||
super(`Service ${stringifyIdentifier(identifier)} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyIdentifier(identifier: ServiceIdentifierValue) {
|
||||
return `[${identifier.identifierName}]${
|
||||
identifier.variant !== DEFAULT_SERVICE_VARIANT
|
||||
? `(${identifier.variant})`
|
||||
: ''
|
||||
}`;
|
||||
}
|
||||
|
||||
function stringifyDependencyStack(dependencyStack: ServiceIdentifierValue[]) {
|
||||
return dependencyStack
|
||||
.map(identifier => `${stringifyIdentifier(identifier)}`)
|
||||
.join(' -> ');
|
||||
}
|
||||
114
blocksuite/framework/global/src/di/identifier.ts
Normal file
114
blocksuite/framework/global/src/di/identifier.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { DEFAULT_SERVICE_VARIANT } from './consts.js';
|
||||
import { stableHash } from './stable-hash.js';
|
||||
import type {
|
||||
ServiceIdentifier,
|
||||
ServiceIdentifierValue,
|
||||
ServiceVariant,
|
||||
Type,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* create a ServiceIdentifier.
|
||||
*
|
||||
* ServiceIdentifier is used to identify a certain type of service. With the identifier, you can reference one or more services
|
||||
* without knowing the specific implementation, thereby achieving
|
||||
* [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // define a interface
|
||||
* interface Storage {
|
||||
* get(key: string): string | null;
|
||||
* set(key: string, value: string): void;
|
||||
* }
|
||||
*
|
||||
* // create a identifier
|
||||
* // NOTICE: Highly recommend to use the interface name as the identifier name,
|
||||
* // so that it is easy to understand. and it is legal to do so in TypeScript.
|
||||
* const Storage = createIdentifier<Storage>('Storage');
|
||||
*
|
||||
* // create a implementation
|
||||
* class LocalStorage implements Storage {
|
||||
* get(key: string): string | null {
|
||||
* return localStorage.getItem(key);
|
||||
* }
|
||||
* set(key: string, value: string): void {
|
||||
* localStorage.setItem(key, value);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // register the implementation to the identifier
|
||||
* services.addImpl(Storage, LocalStorage);
|
||||
*
|
||||
* // get the implementation from the identifier
|
||||
* const storage = services.provider().get(Storage);
|
||||
* storage.set('foo', 'bar');
|
||||
* ```
|
||||
*
|
||||
* With identifier:
|
||||
*
|
||||
* * You can easily replace the implementation of a `Storage` without changing the code that uses it.
|
||||
* * You can easily mock a `Storage` for testing.
|
||||
*
|
||||
* # Variant
|
||||
*
|
||||
* Sometimes, you may want to register multiple implementations for the same interface.
|
||||
* For example, you may want have both `LocalStorage` and `SessionStorage` for `Storage`,
|
||||
* and use them in same time.
|
||||
*
|
||||
* In this case, you can use `variant` to distinguish them.
|
||||
*
|
||||
* ```ts
|
||||
* const Storage = createIdentifier<Storage>('Storage');
|
||||
* const LocalStorage = Storage('local');
|
||||
* const SessionStorage = Storage('session');
|
||||
*
|
||||
* services.addImpl(LocalStorage, LocalStorageImpl);
|
||||
* services.addImpl(SessionStorage, SessionStorageImpl);
|
||||
*
|
||||
* // get the implementation from the identifier
|
||||
* const localStorage = services.provider().get(LocalStorage);
|
||||
* const sessionStorage = services.provider().get(SessionStorage);
|
||||
* const storage = services.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
|
||||
* ```
|
||||
*
|
||||
* @param name unique name of the identifier.
|
||||
* @param variant The default variant name of the identifier, can be overridden by `identifier("variant")`.
|
||||
*/
|
||||
export function createIdentifier<T>(
|
||||
name: string,
|
||||
variant: ServiceVariant = DEFAULT_SERVICE_VARIANT
|
||||
): ServiceIdentifier<T> &
|
||||
(<U extends T = T>(variant: ServiceVariant) => ServiceIdentifier<U>) {
|
||||
return Object.assign(
|
||||
<U extends T = T>(variant: ServiceVariant) => {
|
||||
return createIdentifier<U>(name, variant);
|
||||
},
|
||||
{
|
||||
identifierName: name,
|
||||
variant,
|
||||
}
|
||||
) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the constructor into a ServiceIdentifier.
|
||||
* As we always deal with ServiceIdentifier in the DI container.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function createIdentifierFromConstructor<T>(
|
||||
target: Type<T>
|
||||
): ServiceIdentifier<T> {
|
||||
return createIdentifier<T>(`${target.name}${stableHash(target)}`);
|
||||
}
|
||||
|
||||
export function parseIdentifier(input: any): ServiceIdentifierValue {
|
||||
if (input.identifierName) {
|
||||
return input as ServiceIdentifierValue;
|
||||
} else if (typeof input === 'function' && input.name) {
|
||||
return createIdentifierFromConstructor(input);
|
||||
} else {
|
||||
throw new Error('Input is not a service identifier.');
|
||||
}
|
||||
}
|
||||
7
blocksuite/framework/global/src/di/index.ts
Normal file
7
blocksuite/framework/global/src/di/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './consts.js';
|
||||
export * from './container.js';
|
||||
export * from './error.js';
|
||||
export * from './identifier.js';
|
||||
export * from './provider.js';
|
||||
export * from './scope.js';
|
||||
export * from './types.js';
|
||||
219
blocksuite/framework/global/src/di/provider.ts
Normal file
219
blocksuite/framework/global/src/di/provider.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { Container } from './container.js';
|
||||
import {
|
||||
CircularDependencyError,
|
||||
MissingDependencyError,
|
||||
RecursionLimitError,
|
||||
ServiceNotFoundError,
|
||||
} from './error.js';
|
||||
import { parseIdentifier } from './identifier.js';
|
||||
import type {
|
||||
GeneralServiceIdentifier,
|
||||
ServiceIdentifierValue,
|
||||
ServiceVariant,
|
||||
} from './types.js';
|
||||
|
||||
export interface ResolveOptions {
|
||||
sameScope?: boolean;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export abstract class ServiceProvider {
|
||||
abstract container: Container;
|
||||
|
||||
get<T>(identifier: GeneralServiceIdentifier<T>, options?: ResolveOptions): T {
|
||||
return this.getRaw(parseIdentifier(identifier), {
|
||||
...options,
|
||||
optional: false,
|
||||
});
|
||||
}
|
||||
|
||||
getAll<T>(
|
||||
identifier: GeneralServiceIdentifier<T>,
|
||||
options?: ResolveOptions
|
||||
): Map<ServiceVariant, T> {
|
||||
return this.getAllRaw(parseIdentifier(identifier), {
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
abstract getAllRaw(
|
||||
identifier: ServiceIdentifierValue,
|
||||
options?: ResolveOptions
|
||||
): Map<ServiceVariant, any>;
|
||||
|
||||
getOptional<T>(
|
||||
identifier: GeneralServiceIdentifier<T>,
|
||||
options?: ResolveOptions
|
||||
): T | null {
|
||||
return this.getRaw(parseIdentifier(identifier), {
|
||||
...options,
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
abstract getRaw(
|
||||
identifier: ServiceIdentifierValue,
|
||||
options?: ResolveOptions
|
||||
): any;
|
||||
}
|
||||
|
||||
export class ServiceCachePool {
|
||||
cache = new Map<string, Map<ServiceVariant, any>>();
|
||||
|
||||
getOrInsert(identifier: ServiceIdentifierValue, insert: () => any) {
|
||||
const cache = this.cache.get(identifier.identifierName) ?? new Map();
|
||||
if (!cache.has(identifier.variant)) {
|
||||
cache.set(identifier.variant, insert());
|
||||
}
|
||||
const cached = cache.get(identifier.variant);
|
||||
this.cache.set(identifier.identifierName, cache);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceResolver extends ServiceProvider {
|
||||
container = this.provider.container;
|
||||
|
||||
constructor(
|
||||
readonly provider: BasicServiceProvider,
|
||||
readonly depth = 0,
|
||||
readonly stack: ServiceIdentifierValue[] = []
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getAllRaw(
|
||||
identifier: ServiceIdentifierValue,
|
||||
{ sameScope = false }: ResolveOptions = {}
|
||||
): Map<ServiceVariant, any> {
|
||||
const vars = this.provider.container.getFactoryAll(
|
||||
identifier,
|
||||
this.provider.scope
|
||||
);
|
||||
|
||||
if (vars === undefined) {
|
||||
if (this.provider.parent && !sameScope) {
|
||||
return this.provider.parent.getAllRaw(identifier);
|
||||
}
|
||||
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const result = new Map<ServiceVariant, any>();
|
||||
|
||||
for (const [variant, factory] of vars) {
|
||||
const service = this.provider.cache.getOrInsert(
|
||||
{ identifierName: identifier.identifierName, variant },
|
||||
() => {
|
||||
const nextResolver = this.track(identifier);
|
||||
try {
|
||||
return factory(nextResolver);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceNotFoundError) {
|
||||
throw new MissingDependencyError(
|
||||
identifier,
|
||||
err.identifier,
|
||||
this.stack
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
result.set(variant, service);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getRaw(
|
||||
identifier: ServiceIdentifierValue,
|
||||
{ sameScope = false, optional = false }: ResolveOptions = {}
|
||||
) {
|
||||
const factory = this.provider.container.getFactory(
|
||||
identifier,
|
||||
this.provider.scope
|
||||
);
|
||||
if (!factory) {
|
||||
if (this.provider.parent && !sameScope) {
|
||||
return this.provider.parent.getRaw(identifier, {
|
||||
sameScope,
|
||||
optional,
|
||||
});
|
||||
}
|
||||
|
||||
if (optional) {
|
||||
return undefined;
|
||||
}
|
||||
throw new ServiceNotFoundError(identifier);
|
||||
}
|
||||
|
||||
return this.provider.cache.getOrInsert(identifier, () => {
|
||||
const nextResolver = this.track(identifier);
|
||||
try {
|
||||
return factory(nextResolver);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceNotFoundError) {
|
||||
throw new MissingDependencyError(
|
||||
identifier,
|
||||
err.identifier,
|
||||
this.stack
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
track(identifier: ServiceIdentifierValue): ServiceResolver {
|
||||
const depth = this.depth + 1;
|
||||
if (depth >= 100) {
|
||||
throw new RecursionLimitError();
|
||||
}
|
||||
const circular = this.stack.find(
|
||||
i =>
|
||||
i.identifierName === identifier.identifierName &&
|
||||
i.variant === identifier.variant
|
||||
);
|
||||
if (circular) {
|
||||
throw new CircularDependencyError([...this.stack, identifier]);
|
||||
}
|
||||
|
||||
return new ServiceResolver(this.provider, depth, [
|
||||
...this.stack,
|
||||
identifier,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export class BasicServiceProvider extends ServiceProvider {
|
||||
readonly cache = new ServiceCachePool();
|
||||
|
||||
readonly container: Container;
|
||||
|
||||
constructor(
|
||||
container: Container,
|
||||
readonly scope: string[],
|
||||
readonly parent: ServiceProvider | null
|
||||
) {
|
||||
super();
|
||||
this.container = container.clone();
|
||||
this.container.addValue(ServiceProvider, this, {
|
||||
scope: scope,
|
||||
override: true,
|
||||
});
|
||||
}
|
||||
|
||||
getAllRaw(
|
||||
identifier: ServiceIdentifierValue,
|
||||
options?: ResolveOptions
|
||||
): Map<ServiceVariant, any> {
|
||||
const resolver = new ServiceResolver(this);
|
||||
return resolver.getAllRaw(identifier, options);
|
||||
}
|
||||
|
||||
getRaw(identifier: ServiceIdentifierValue, options?: ResolveOptions) {
|
||||
const resolver = new ServiceResolver(this);
|
||||
return resolver.getRaw(identifier, options);
|
||||
}
|
||||
}
|
||||
13
blocksuite/framework/global/src/di/scope.ts
Normal file
13
blocksuite/framework/global/src/di/scope.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ROOT_SCOPE } from './consts.js';
|
||||
import type { ServiceScope } from './types.js';
|
||||
|
||||
export function createScope(
|
||||
name: string,
|
||||
base: ServiceScope = ROOT_SCOPE
|
||||
): ServiceScope {
|
||||
return [...base, name];
|
||||
}
|
||||
|
||||
export function stringifyScope(scope: ServiceScope): string {
|
||||
return scope.join('/');
|
||||
}
|
||||
59
blocksuite/framework/global/src/di/stable-hash.ts
Normal file
59
blocksuite/framework/global/src/di/stable-hash.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// copied from https://github.com/shuding/stable-hash
|
||||
|
||||
// Use WeakMap to store the object-key mapping so the objects can still be
|
||||
// garbage collected. WeakMap uses a hashtable under the hood, so the lookup
|
||||
// complexity is almost O(1).
|
||||
const table = new WeakMap<object, string>();
|
||||
|
||||
// A counter of the key.
|
||||
let counter = 0;
|
||||
|
||||
// A stable hash implementation that supports:
|
||||
// - Fast and ensures unique hash properties
|
||||
// - Handles unserializable values
|
||||
// - Handles object key ordering
|
||||
// - Generates short results
|
||||
//
|
||||
// This is not a serialization function, and the result is not guaranteed to be
|
||||
// parsable.
|
||||
export function stableHash(arg: any): string {
|
||||
const type = typeof arg;
|
||||
const constructor = arg && arg.constructor;
|
||||
const isDate = constructor === Date;
|
||||
|
||||
if (Object(arg) === arg && !isDate && constructor !== RegExp) {
|
||||
// Object/function, not null/date/regexp. Use WeakMap to store the id first.
|
||||
// If it's already hashed, directly return the result.
|
||||
let result = table.get(arg);
|
||||
if (result) return result;
|
||||
// Store the hash first for circular reference detection before entering the
|
||||
// recursive `stableHash` calls.
|
||||
// For other objects like set and map, we use this id directly as the hash.
|
||||
result = ++counter + '~';
|
||||
table.set(arg, result);
|
||||
let index: any;
|
||||
|
||||
if (constructor === Array) {
|
||||
// Array.
|
||||
result = '@';
|
||||
for (index = 0; index < arg.length; index++) {
|
||||
result += stableHash(arg[index]) + ',';
|
||||
}
|
||||
table.set(arg, result);
|
||||
} else if (constructor === Object) {
|
||||
// Object, sort keys.
|
||||
result = '#';
|
||||
const keys = Object.keys(arg).sort();
|
||||
while ((index = keys.pop() as string) !== undefined) {
|
||||
if (arg[index] !== undefined) {
|
||||
result += index + ':' + stableHash(arg[index]) + ',';
|
||||
}
|
||||
}
|
||||
table.set(arg, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (isDate) return arg.toJSON();
|
||||
if (type === 'symbol') return arg.toString();
|
||||
return type === 'string' ? JSON.stringify(arg) : '' + arg;
|
||||
}
|
||||
37
blocksuite/framework/global/src/di/types.ts
Normal file
37
blocksuite/framework/global/src/di/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ServiceProvider } from './provider.js';
|
||||
|
||||
export type Type<T = any> = abstract new (...args: any) => T;
|
||||
|
||||
export type ServiceFactory<T = any> = (provider: ServiceProvider) => T;
|
||||
export type ServiceVariant = string;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export type ServiceScope = string[];
|
||||
|
||||
export type ServiceIdentifierValue = {
|
||||
identifierName: string;
|
||||
variant: ServiceVariant;
|
||||
};
|
||||
|
||||
export type GeneralServiceIdentifier<T = any> = ServiceIdentifier<T> | Type<T>;
|
||||
|
||||
export type ServiceIdentifier<T> = {
|
||||
identifierName: string;
|
||||
variant: ServiceVariant;
|
||||
__TYPE__: T;
|
||||
};
|
||||
|
||||
export type ServiceIdentifierType<T> =
|
||||
T extends ServiceIdentifier<infer R>
|
||||
? R
|
||||
: T extends Type<infer R>
|
||||
? R
|
||||
: never;
|
||||
|
||||
export type TypesToDeps<T extends any[]> = {
|
||||
[index in keyof T]:
|
||||
| GeneralServiceIdentifier<T[index]>
|
||||
| (T[index] extends (infer I)[] ? [GeneralServiceIdentifier<I>] : never);
|
||||
};
|
||||
106
blocksuite/framework/global/src/disposable/index.ts
Normal file
106
blocksuite/framework/global/src/disposable/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
|
||||
type DisposeCallback = () => void;
|
||||
|
||||
export interface Disposable {
|
||||
dispose: DisposeCallback;
|
||||
}
|
||||
|
||||
export type DisposableMember =
|
||||
| Disposable
|
||||
| Subscription
|
||||
| Subject<any>
|
||||
| DisposeCallback;
|
||||
|
||||
export class DisposableGroup {
|
||||
private _disposables: DisposableMember[] = [];
|
||||
|
||||
private _disposed = false;
|
||||
|
||||
get disposed() {
|
||||
return this._disposed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to group to be disposed with others.
|
||||
* This will be immediately disposed if this group has already been disposed.
|
||||
*/
|
||||
add(d: DisposableMember) {
|
||||
if (this._disposed) {
|
||||
disposeMember(d);
|
||||
return;
|
||||
}
|
||||
this._disposables.push(d);
|
||||
}
|
||||
|
||||
addFromEvent<N extends keyof WindowEventMap>(
|
||||
element: Window,
|
||||
eventName: N,
|
||||
handler: (e: WindowEventMap[N]) => void,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
addFromEvent<N extends keyof DocumentEventMap>(
|
||||
element: Document,
|
||||
eventName: N,
|
||||
handler: (e: DocumentEventMap[N]) => void,
|
||||
eventOptions?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
addFromEvent<N extends keyof HTMLElementEventMap>(
|
||||
element: HTMLElement,
|
||||
eventName: N,
|
||||
handler: (e: HTMLElementEventMap[N]) => void,
|
||||
eventOptions?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
addFromEvent<N extends keyof VisualViewportEventMap>(
|
||||
element: VisualViewport,
|
||||
eventName: N,
|
||||
handler: (e: VisualViewportEventMap[N]) => void,
|
||||
eventOptions?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
addFromEvent<N extends keyof VirtualKeyboardEventMap>(
|
||||
element: VirtualKeyboard,
|
||||
eventName: N,
|
||||
handler: (e: VirtualKeyboardEventMap[N]) => void,
|
||||
eventOptions?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
|
||||
addFromEvent(
|
||||
target: HTMLElement | Window | Document | VisualViewport | VirtualKeyboard,
|
||||
type: string,
|
||||
handler: (e: Event) => void,
|
||||
eventOptions?: boolean | AddEventListenerOptions
|
||||
) {
|
||||
this.add({
|
||||
dispose: () => {
|
||||
target.removeEventListener(type, handler as () => void, eventOptions);
|
||||
},
|
||||
});
|
||||
target.addEventListener(type, handler as () => void, eventOptions);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
disposeAll(this._disposables);
|
||||
this._disposables = [];
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeMember(disposable: DisposableMember) {
|
||||
try {
|
||||
if (disposable instanceof Subscription) {
|
||||
disposable.unsubscribe();
|
||||
} else if (disposable instanceof Subject) {
|
||||
disposable.complete();
|
||||
} else if (typeof disposable === 'function') {
|
||||
disposable();
|
||||
} else {
|
||||
disposable.dispose();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function disposeAll(disposables: DisposableMember[]) {
|
||||
disposables.forEach(disposeMember);
|
||||
}
|
||||
31
blocksuite/framework/global/src/env/index.ts
vendored
Normal file
31
blocksuite/framework/global/src/env/index.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
const agent = globalThis.navigator?.userAgent ?? '';
|
||||
const platform = globalThis.navigator?.platform || globalThis.process?.platform;
|
||||
|
||||
export const IS_WEB =
|
||||
typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||
|
||||
export const IS_NODE = typeof process !== 'undefined' && !IS_WEB;
|
||||
|
||||
export const IS_SAFARI = /Apple Computer/.test(globalThis.navigator?.vendor);
|
||||
|
||||
export const IS_FIREFOX =
|
||||
IS_WEB && navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
|
||||
export const IS_ANDROID = /Android \d/.test(agent);
|
||||
|
||||
export const IS_IOS =
|
||||
IS_SAFARI &&
|
||||
(/Mobile\/\w+/.test(agent) || globalThis.navigator?.maxTouchPoints > 2);
|
||||
|
||||
export const IS_MAC = /Mac/i.test(platform) || /darwin/.test(platform);
|
||||
|
||||
export const IS_IPAD =
|
||||
/iPad/i.test(platform) ||
|
||||
/iPad/i.test(agent) ||
|
||||
(/Macintosh/i.test(agent) && globalThis.navigator?.maxTouchPoints > 2);
|
||||
|
||||
export const IS_WINDOWS = /Win/.test(platform) || /win32/.test(platform);
|
||||
|
||||
export const IS_LINUX = /Linux/.test(platform);
|
||||
|
||||
export const IS_MOBILE = IS_IOS || IS_IPAD || IS_ANDROID;
|
||||
30
blocksuite/framework/global/src/exceptions/code.ts
Normal file
30
blocksuite/framework/global/src/exceptions/code.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export enum ErrorCode {
|
||||
DefaultRuntimeError = 1,
|
||||
ReactiveProxyError,
|
||||
DocCollectionError,
|
||||
ModelCRUDError,
|
||||
ValueNotExists,
|
||||
ValueNotInstanceOf,
|
||||
ValueNotEqual,
|
||||
MigrationError,
|
||||
SchemaValidateError,
|
||||
TransformerError,
|
||||
InlineEditorError,
|
||||
TransformerNotImplementedError,
|
||||
EdgelessExportError,
|
||||
CommandError,
|
||||
EventDispatcherError,
|
||||
SelectionError,
|
||||
GfxBlockElementError,
|
||||
MissingViewModelError,
|
||||
DatabaseBlockError,
|
||||
ParsingError,
|
||||
UserAbortError,
|
||||
ExecutionError,
|
||||
|
||||
// Fatal error should be greater than 10000
|
||||
DefaultFatalError = 10000,
|
||||
NoRootModelError,
|
||||
NoSurfaceModelError,
|
||||
NoneSupportedSSRError,
|
||||
}
|
||||
36
blocksuite/framework/global/src/exceptions/index.ts
Normal file
36
blocksuite/framework/global/src/exceptions/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ErrorCode } from './code.js';
|
||||
|
||||
export class BlockSuiteError extends Error {
|
||||
static ErrorCode = ErrorCode;
|
||||
|
||||
code: ErrorCode;
|
||||
|
||||
isFatal: boolean;
|
||||
|
||||
constructor(code: ErrorCode, message: string, options?: { cause: Error }) {
|
||||
super(message, options);
|
||||
this.name = 'BlockSuiteError';
|
||||
this.code = code;
|
||||
this.isFatal = code >= 10000;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleError(error: Error) {
|
||||
if (!(error instanceof BlockSuiteError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.isFatal) {
|
||||
throw new Error(
|
||||
'A fatal error for BlockSuite occurs, please contact the team if you find this.',
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
console.error(
|
||||
"A runtime error for BlockSuite occurs, you can ignore this error if it won't break the user experience."
|
||||
);
|
||||
console.error(error.stack);
|
||||
}
|
||||
|
||||
export * from './code.js';
|
||||
175
blocksuite/framework/global/src/gfx/bound.ts
Normal file
175
blocksuite/framework/global/src/gfx/bound.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Bound, getIBoundFromPoints, type IBound } from './model/bound.js';
|
||||
import { type IVec } from './model/vec.js';
|
||||
|
||||
function getExpandedBound(a: IBound, b: IBound): IBound {
|
||||
const minX = Math.min(a.x, b.x);
|
||||
const minY = Math.min(a.y, b.y);
|
||||
const maxX = Math.max(a.x + a.w, b.x + b.w);
|
||||
const maxY = Math.max(a.y + a.h, b.y + b.h);
|
||||
const width = Math.abs(maxX - minX);
|
||||
const height = Math.abs(maxY - minY);
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPointsFromBoundWithRotation(
|
||||
bounds: IBound,
|
||||
getPoints: (bounds: IBound) => IVec[] = ({ x, y, w, h }: IBound) => [
|
||||
// left-top
|
||||
[x, y],
|
||||
// right-top
|
||||
[x + w, y],
|
||||
// right-bottom
|
||||
[x + w, y + h],
|
||||
// left-bottom
|
||||
[x, y + h],
|
||||
],
|
||||
resPadding: [number, number] = [0, 0]
|
||||
): IVec[] {
|
||||
const { rotate } = bounds;
|
||||
let points = getPoints({
|
||||
x: bounds.x - resPadding[1],
|
||||
y: bounds.y - resPadding[0],
|
||||
w: bounds.w + resPadding[1] * 2,
|
||||
h: bounds.h + resPadding[0] * 2,
|
||||
});
|
||||
|
||||
if (rotate) {
|
||||
const { x, y, w, h } = bounds;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
const m = new DOMMatrix()
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-cx, -cy);
|
||||
|
||||
points = points.map(point => {
|
||||
const { x, y } = new DOMPoint(...point).matrixTransform(m);
|
||||
return [x, y];
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export function getQuadBoundWithRotation(bounds: IBound): DOMRect {
|
||||
const { x, y, w, h, rotate } = bounds;
|
||||
const rect = new DOMRect(x, y, w, h);
|
||||
|
||||
if (!rotate) return rect;
|
||||
|
||||
return new DOMQuad(
|
||||
...getPointsFromBoundWithRotation(bounds).map(
|
||||
point => new DOMPoint(...point)
|
||||
)
|
||||
).getBounds();
|
||||
}
|
||||
|
||||
export function getBoundWithRotation(bound: IBound): IBound {
|
||||
const { x, y, width: w, height: h } = getQuadBoundWithRotation(bound);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the common bound of the given bounds.
|
||||
* The rotation of the bounds is not considered.
|
||||
* @param bounds
|
||||
* @returns
|
||||
*/
|
||||
export function getCommonBound(bounds: IBound[]): Bound | null {
|
||||
if (!bounds.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bounds.length === 1) {
|
||||
const { x, y, w, h } = bounds[0];
|
||||
return new Bound(x, y, w, h);
|
||||
}
|
||||
|
||||
let result = bounds[0];
|
||||
|
||||
for (let i = 1; i < bounds.length; i++) {
|
||||
result = getExpandedBound(result, bounds[i]);
|
||||
}
|
||||
|
||||
return new Bound(result.x, result.y, result.w, result.h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `getCommonBound`, but considers the rotation of the bounds.
|
||||
* @returns
|
||||
*/
|
||||
export function getCommonBoundWithRotation(bounds: IBound[]): Bound {
|
||||
if (bounds.length === 0) {
|
||||
return new Bound(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return bounds.reduce(
|
||||
(pre, bound) => {
|
||||
return pre.unite(
|
||||
bound instanceof Bound ? bound : Bound.from(getBoundWithRotation(bound))
|
||||
);
|
||||
},
|
||||
Bound.from(getBoundWithRotation(bounds[0]))
|
||||
);
|
||||
}
|
||||
|
||||
export function getBoundFromPoints(points: IVec[]) {
|
||||
return Bound.from(getIBoundFromPoints(points));
|
||||
}
|
||||
|
||||
export function inflateBound(bound: IBound, delta: number) {
|
||||
const half = delta / 2;
|
||||
|
||||
const newBound = new Bound(
|
||||
bound.x - half,
|
||||
bound.y - half,
|
||||
bound.w + delta,
|
||||
bound.h + delta
|
||||
);
|
||||
|
||||
if (newBound.w <= 0 || newBound.h <= 0) {
|
||||
throw new Error('Invalid delta range or bound size.');
|
||||
}
|
||||
|
||||
return newBound;
|
||||
}
|
||||
|
||||
export function transformPointsToNewBound<T extends { x: number; y: number }>(
|
||||
points: T[],
|
||||
oldBound: IBound,
|
||||
oldMargin: number,
|
||||
newBound: IBound,
|
||||
newMargin: number
|
||||
) {
|
||||
const wholeOldMargin = oldMargin * 2;
|
||||
const wholeNewMargin = newMargin * 2;
|
||||
const oldW = Math.max(oldBound.w - wholeOldMargin, 1);
|
||||
const oldH = Math.max(oldBound.h - wholeOldMargin, 1);
|
||||
const newW = Math.max(newBound.w - wholeNewMargin, 1);
|
||||
const newH = Math.max(newBound.h - wholeNewMargin, 1);
|
||||
|
||||
const transformedPoints = points.map(p => {
|
||||
return {
|
||||
...p,
|
||||
x: newW * ((p.x - oldMargin) / oldW) + newMargin,
|
||||
y: newH * ((p.y - oldMargin) / oldH) + newMargin,
|
||||
} as T;
|
||||
});
|
||||
|
||||
return {
|
||||
points: transformedPoints,
|
||||
bound: new Bound(
|
||||
newBound.x,
|
||||
newBound.y,
|
||||
newW + wholeNewMargin,
|
||||
newH + wholeNewMargin
|
||||
),
|
||||
};
|
||||
}
|
||||
407
blocksuite/framework/global/src/gfx/curve.ts
Normal file
407
blocksuite/framework/global/src/gfx/curve.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
// control coords are not relative to start or end
|
||||
import { CURVETIME_EPSILON, isZero } from './math.js';
|
||||
import { Bound, type IVec, PointLocation, Vec } from './model/index.js';
|
||||
|
||||
export type BezierCurveParameters = [
|
||||
start: IVec,
|
||||
control1: IVec,
|
||||
control2: IVec,
|
||||
end: IVec,
|
||||
];
|
||||
|
||||
function evaluate(
|
||||
v: BezierCurveParameters,
|
||||
t: number,
|
||||
type: number,
|
||||
normalized: boolean
|
||||
): IVec | null {
|
||||
if (t == null || t < 0 || t > 1) return null;
|
||||
const x0 = v[0][0],
|
||||
y0 = v[0][1],
|
||||
x3 = v[3][0],
|
||||
y3 = v[3][1];
|
||||
let x1 = v[1][0],
|
||||
y1 = v[1][1],
|
||||
x2 = v[2][0],
|
||||
y2 = v[2][1];
|
||||
|
||||
if (isZero(x1 - x0) && isZero(y1 - y0)) {
|
||||
x1 = x0;
|
||||
y1 = y0;
|
||||
}
|
||||
if (isZero(x2 - x3) && isZero(y2 - y3)) {
|
||||
x2 = x3;
|
||||
y2 = y3;
|
||||
}
|
||||
// Calculate the polynomial coefficients.
|
||||
const cx = 3 * (x1 - x0),
|
||||
bx = 3 * (x2 - x1) - cx,
|
||||
ax = x3 - x0 - cx - bx,
|
||||
cy = 3 * (y1 - y0),
|
||||
by = 3 * (y2 - y1) - cy,
|
||||
ay = y3 - y0 - cy - by;
|
||||
let x, y;
|
||||
if (type === 0) {
|
||||
// type === 0: getPoint()
|
||||
x = t === 0 ? x0 : t === 1 ? x3 : ((ax * t + bx) * t + cx) * t + x0;
|
||||
y = t === 0 ? y0 : t === 1 ? y3 : ((ay * t + by) * t + cy) * t + y0;
|
||||
} else {
|
||||
// type === 1: getTangent()
|
||||
// type === 2: getNormal()
|
||||
// type === 3: getCurvature()
|
||||
const tMin = CURVETIME_EPSILON,
|
||||
tMax = 1 - tMin;
|
||||
if (t < tMin) {
|
||||
x = cx;
|
||||
y = cy;
|
||||
} else if (t > tMax) {
|
||||
x = 3 * (x3 - x2);
|
||||
y = 3 * (y3 - y2);
|
||||
} else {
|
||||
x = (3 * ax * t + 2 * bx) * t + cx;
|
||||
y = (3 * ay * t + 2 * by) * t + cy;
|
||||
}
|
||||
if (normalized) {
|
||||
if (x === 0 && y === 0 && (t < tMin || t > tMax)) {
|
||||
x = x2 - x1;
|
||||
y = y2 - y1;
|
||||
}
|
||||
const len = Math.sqrt(x * x + y * y);
|
||||
if (len) {
|
||||
x /= len;
|
||||
y /= len;
|
||||
}
|
||||
}
|
||||
if (type === 3) {
|
||||
const x2 = 6 * ax * t + 2 * bx,
|
||||
y2 = 6 * ay * t + 2 * by,
|
||||
d = Math.pow(x * x + y * y, 3 / 2);
|
||||
x = d !== 0 ? (x * y2 - y * x2) / d : 0;
|
||||
y = 0;
|
||||
}
|
||||
}
|
||||
return type === 2 ? [y, -x] : [x, y];
|
||||
}
|
||||
|
||||
export function getBezierPoint(values: BezierCurveParameters, t: number) {
|
||||
return evaluate(values, t, 0, false);
|
||||
}
|
||||
|
||||
export function getBezierTangent(values: BezierCurveParameters, t: number) {
|
||||
return evaluate(values, t, 1, true);
|
||||
}
|
||||
|
||||
export function getBezierNormal(values: BezierCurveParameters, t: number) {
|
||||
return evaluate(values, t, 2, true);
|
||||
}
|
||||
|
||||
export function getBezierCurvature(values: BezierCurveParameters, t: number) {
|
||||
return evaluate(values, t, 3, false)?.[0];
|
||||
}
|
||||
|
||||
export function getBezierNearestTime(
|
||||
values: BezierCurveParameters,
|
||||
point: IVec
|
||||
) {
|
||||
const count = 100;
|
||||
let minDist = Infinity,
|
||||
minT = 0;
|
||||
|
||||
function refine(t: number) {
|
||||
if (t >= 0 && t <= 1) {
|
||||
const tmpPoint = getBezierPoint(values, t);
|
||||
if (!tmpPoint) return false;
|
||||
const dist = Vec.dist2(point, tmpPoint);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
minT = t;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i <= count; i++) refine(i / count);
|
||||
|
||||
let step = 1 / (count * 2);
|
||||
while (step > CURVETIME_EPSILON) {
|
||||
if (!refine(minT - step) && !refine(minT + step)) step /= 2;
|
||||
}
|
||||
return minT;
|
||||
}
|
||||
|
||||
export function getBezierNearestPoint(
|
||||
values: BezierCurveParameters,
|
||||
point: IVec
|
||||
) {
|
||||
const t = getBezierNearestTime(values, point);
|
||||
const pointOnCurve = getBezierPoint(values, t);
|
||||
return pointOnCurve;
|
||||
}
|
||||
|
||||
export function getBezierParameters(
|
||||
points: PointLocation[]
|
||||
): BezierCurveParameters {
|
||||
if (points.length === 0) {
|
||||
const point = new PointLocation();
|
||||
return [point, point, point, point];
|
||||
}
|
||||
|
||||
// Fallback for degenerate Bezier curve (all points are at the same position)
|
||||
if (points.length === 1) {
|
||||
const point = points[0];
|
||||
return [point, point, point, point];
|
||||
}
|
||||
|
||||
return [points[0], points[0].absOut, points[1].absIn, points[1]];
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/2587751/an-algorithm-to-find-bounding-box-of-closed-bezier-curves
|
||||
export function getBezierCurveBoundingBox(values: BezierCurveParameters) {
|
||||
const [start, controlPoint1, controlPoint2, end] = values;
|
||||
|
||||
const [x0, y0] = start;
|
||||
const [x1, y1] = controlPoint1;
|
||||
const [x2, y2] = controlPoint2;
|
||||
const [x3, y3] = end;
|
||||
|
||||
const points = []; // local extremes
|
||||
const tvalues = []; // t values of local extremes
|
||||
const bounds: [number[], number[]] = [[], []];
|
||||
|
||||
let a;
|
||||
let b;
|
||||
let c;
|
||||
let t;
|
||||
let t1;
|
||||
let t2;
|
||||
let b2ac;
|
||||
let sqrtb2ac;
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
if (i === 0) {
|
||||
b = 6 * x0 - 12 * x1 + 6 * x2;
|
||||
a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3;
|
||||
c = 3 * x1 - 3 * x0;
|
||||
} else {
|
||||
b = 6 * y0 - 12 * y1 + 6 * y2;
|
||||
a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3;
|
||||
c = 3 * y1 - 3 * y0;
|
||||
}
|
||||
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
if (Math.abs(b) < 1e-12) {
|
||||
continue;
|
||||
}
|
||||
|
||||
t = -c / b;
|
||||
if (t > 0 && t < 1) tvalues.push(t);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
b2ac = b * b - 4 * c * a;
|
||||
sqrtb2ac = Math.sqrt(b2ac);
|
||||
|
||||
if (b2ac < 0) continue;
|
||||
|
||||
t1 = (-b + sqrtb2ac) / (2 * a);
|
||||
if (t1 > 0 && t1 < 1) tvalues.push(t1);
|
||||
|
||||
t2 = (-b - sqrtb2ac) / (2 * a);
|
||||
if (t2 > 0 && t2 < 1) tvalues.push(t2);
|
||||
}
|
||||
|
||||
let x;
|
||||
let y;
|
||||
let mt;
|
||||
let j = tvalues.length;
|
||||
const jlen = j;
|
||||
|
||||
while (j) {
|
||||
j -= 1;
|
||||
t = tvalues[j];
|
||||
mt = 1 - t;
|
||||
|
||||
x =
|
||||
mt * mt * mt * x0 +
|
||||
3 * mt * mt * t * x1 +
|
||||
3 * mt * t * t * x2 +
|
||||
t * t * t * x3;
|
||||
bounds[0][j] = x;
|
||||
|
||||
y =
|
||||
mt * mt * mt * y0 +
|
||||
3 * mt * mt * t * y1 +
|
||||
3 * mt * t * t * y2 +
|
||||
t * t * t * y3;
|
||||
|
||||
bounds[1][j] = y;
|
||||
points[j] = { X: x, Y: y };
|
||||
}
|
||||
|
||||
tvalues[jlen] = 0;
|
||||
tvalues[jlen + 1] = 1;
|
||||
|
||||
points[jlen] = { X: x0, Y: y0 };
|
||||
points[jlen + 1] = { X: x3, Y: y3 };
|
||||
|
||||
bounds[0][jlen] = x0;
|
||||
bounds[1][jlen] = y0;
|
||||
|
||||
bounds[0][jlen + 1] = x3;
|
||||
bounds[1][jlen + 1] = y3;
|
||||
|
||||
tvalues.length = jlen + 2;
|
||||
bounds[0].length = jlen + 2;
|
||||
bounds[1].length = jlen + 2;
|
||||
points.length = jlen + 2;
|
||||
|
||||
const left = Math.min.apply(null, bounds[0]);
|
||||
const top = Math.min.apply(null, bounds[1]);
|
||||
const right = Math.max.apply(null, bounds[0]);
|
||||
const bottom = Math.max.apply(null, bounds[1]);
|
||||
|
||||
return new Bound(left, top, right - left, bottom - top);
|
||||
}
|
||||
|
||||
// https://pomax.github.io/bezierjs/#intersect-line
|
||||
// MIT Licence
|
||||
|
||||
// cube root function yielding real roots
|
||||
function crt(v: number) {
|
||||
return v < 0 ? -Math.pow(-v, 1 / 3) : Math.pow(v, 1 / 3);
|
||||
}
|
||||
|
||||
function align(points: BezierCurveParameters, [start, end]: IVec[]) {
|
||||
const tx = start[0],
|
||||
ty = start[1],
|
||||
a = -Math.atan2(end[1] - ty, end[0] - tx),
|
||||
d = function ([x, y]: IVec) {
|
||||
return [
|
||||
(x - tx) * Math.cos(a) - (y - ty) * Math.sin(a),
|
||||
(x - tx) * Math.sin(a) + (y - ty) * Math.cos(a),
|
||||
];
|
||||
};
|
||||
return points.map(d);
|
||||
}
|
||||
|
||||
function between(v: number, min: number, max: number) {
|
||||
return (
|
||||
(min <= v && v <= max) || approximately(v, min) || approximately(v, max)
|
||||
);
|
||||
}
|
||||
|
||||
function approximately(
|
||||
a: number,
|
||||
b: number,
|
||||
precision?: number,
|
||||
epsilon = 0.000001
|
||||
) {
|
||||
return Math.abs(a - b) <= (precision || epsilon);
|
||||
}
|
||||
|
||||
function roots(points: BezierCurveParameters, line: IVec[]) {
|
||||
const order = points.length - 1;
|
||||
const aligned = align(points, line);
|
||||
const reduce = function (t: number) {
|
||||
return 0 <= t && t <= 1;
|
||||
};
|
||||
|
||||
if (order === 2) {
|
||||
const a = aligned[0][1],
|
||||
b = aligned[1][1],
|
||||
c = aligned[2][1],
|
||||
d = a - 2 * b + c;
|
||||
if (d !== 0) {
|
||||
const m1 = -Math.sqrt(b * b - a * c),
|
||||
m2 = -a + b,
|
||||
v1 = -(m1 + m2) / d,
|
||||
v2 = -(-m1 + m2) / d;
|
||||
return [v1, v2].filter(reduce);
|
||||
} else if (b !== c && d === 0) {
|
||||
return [(2 * b - c) / (2 * b - 2 * c)].filter(reduce);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// see http://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm
|
||||
const pa = aligned[0][1],
|
||||
pb = aligned[1][1],
|
||||
pc = aligned[2][1],
|
||||
pd = aligned[3][1];
|
||||
|
||||
const d = -pa + 3 * pb - 3 * pc + pd;
|
||||
let a = 3 * pa - 6 * pb + 3 * pc,
|
||||
b = -3 * pa + 3 * pb,
|
||||
c = pa;
|
||||
|
||||
if (approximately(d, 0)) {
|
||||
// this is not a cubic curve.
|
||||
if (approximately(a, 0)) {
|
||||
// in fact, this is not a quadratic curve either.
|
||||
if (approximately(b, 0)) {
|
||||
// in fact in fact, there are no solutions.
|
||||
return [];
|
||||
}
|
||||
// linear solution:
|
||||
return [-c / b].filter(reduce);
|
||||
}
|
||||
// quadratic solution:
|
||||
const q = Math.sqrt(b * b - 4 * a * c),
|
||||
a2 = 2 * a;
|
||||
return [(q - b) / a2, (-b - q) / a2].filter(reduce);
|
||||
}
|
||||
|
||||
// at this point, we know we need a cubic solution:
|
||||
|
||||
a /= d;
|
||||
b /= d;
|
||||
c /= d;
|
||||
|
||||
const p = (3 * b - a * a) / 3,
|
||||
p3 = p / 3,
|
||||
q = (2 * a * a * a - 9 * a * b + 27 * c) / 27,
|
||||
q2 = q / 2,
|
||||
discriminant = q2 * q2 + p3 * p3 * p3;
|
||||
|
||||
let u1, v1, x1, x2, x3;
|
||||
if (discriminant < 0) {
|
||||
const mp3 = -p / 3,
|
||||
mp33 = mp3 * mp3 * mp3,
|
||||
r = Math.sqrt(mp33),
|
||||
t = -q / (2 * r),
|
||||
cosphi = t < -1 ? -1 : t > 1 ? 1 : t,
|
||||
phi = Math.acos(cosphi),
|
||||
crtr = crt(r),
|
||||
t1 = 2 * crtr;
|
||||
x1 = t1 * Math.cos(phi / 3) - a / 3;
|
||||
x2 = t1 * Math.cos((phi + Math.PI * 2) / 3) - a / 3;
|
||||
x3 = t1 * Math.cos((phi + 2 * Math.PI * 2) / 3) - a / 3;
|
||||
return [x1, x2, x3].filter(reduce);
|
||||
} else if (discriminant === 0) {
|
||||
u1 = q2 < 0 ? crt(-q2) : -crt(q2);
|
||||
x1 = 2 * u1 - a / 3;
|
||||
x2 = -u1 - a / 3;
|
||||
return [x1, x2].filter(reduce);
|
||||
} else {
|
||||
const sd = Math.sqrt(discriminant);
|
||||
u1 = crt(-q2 + sd);
|
||||
v1 = crt(q2 + sd);
|
||||
return [u1 - v1 - a / 3].filter(reduce);
|
||||
}
|
||||
}
|
||||
|
||||
export function curveIntersects(path: PointLocation[], line: [IVec, IVec]) {
|
||||
const { minX, maxX, minY, maxY } = Bound.fromPoints(line);
|
||||
const points = getBezierParameters(path);
|
||||
const intersectedPoints = roots(points, line)
|
||||
.map(t => getBezierPoint(points, t))
|
||||
.filter(point =>
|
||||
point
|
||||
? between(point[0], minX, maxX) && between(point[1], minY, maxY)
|
||||
: false
|
||||
)
|
||||
.map(point => new PointLocation(point!));
|
||||
return intersectedPoints.length > 0 ? intersectedPoints : null;
|
||||
}
|
||||
8
blocksuite/framework/global/src/gfx/index.ts
Normal file
8
blocksuite/framework/global/src/gfx/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './bound.js';
|
||||
export * from './curve.js';
|
||||
export * from './math.js';
|
||||
export * from './model/index.js';
|
||||
export * from './perfect-freehand/index.js';
|
||||
export * from './polyline.js';
|
||||
export * from './svg-path.js';
|
||||
export * from './xywh.js';
|
||||
569
blocksuite/framework/global/src/gfx/math.ts
Normal file
569
blocksuite/framework/global/src/gfx/math.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
import type { Bound, IBound } from './model/bound.js';
|
||||
import { PointLocation } from './model/point-location.js';
|
||||
import { type IVec, Vec } from './model/vec.js';
|
||||
|
||||
export const EPSILON = 1e-12;
|
||||
export const MACHINE_EPSILON = 1.12e-16;
|
||||
export const PI2 = Math.PI * 2;
|
||||
export const CURVETIME_EPSILON = 1e-8;
|
||||
|
||||
export function randomSeed(): number {
|
||||
return Math.floor(Math.random() * 2 ** 31);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the intersection point of two line segments.
|
||||
*
|
||||
* @param sp - Start point of the first line segment [x, y]
|
||||
* @param ep - End point of the first line segment [x, y]
|
||||
* @param sp2 - Start point of the second line segment [x, y]
|
||||
* @param ep2 - End point of the second line segment [x, y]
|
||||
* @param infinite - If true, treats the lines as infinite lines rather than line segments
|
||||
* @returns The intersection point [x, y] if the lines intersect, null if they are parallel or coincident
|
||||
*
|
||||
* @example
|
||||
* const intersection = lineIntersects([0, 0], [2, 2], [0, 2], [2, 0]);
|
||||
* // Returns [1, 1] - the intersection point of the two line segments
|
||||
*
|
||||
* @example
|
||||
* const parallel = lineIntersects([0, 0], [2, 2], [0, 1], [2, 3], true);
|
||||
* // Returns null - the lines are parallel
|
||||
*/
|
||||
export function lineIntersects(
|
||||
sp: IVec,
|
||||
ep: IVec,
|
||||
sp2: IVec,
|
||||
ep2: IVec,
|
||||
infinite = false
|
||||
): IVec | null {
|
||||
const v1 = Vec.sub(ep, sp);
|
||||
const v2 = Vec.sub(ep2, sp2);
|
||||
const cross = Vec.cpr(v1, v2);
|
||||
// Avoid divisions by 0, and errors when getting too close to 0
|
||||
if (almostEqual(cross, 0, MACHINE_EPSILON)) return null;
|
||||
const d = Vec.sub(sp, sp2);
|
||||
let u1 = Vec.cpr(v2, d) / cross;
|
||||
const u2 = Vec.cpr(v1, d) / cross,
|
||||
// Check the ranges of the u parameters if the line is not
|
||||
// allowed to extend beyond the definition points, but
|
||||
// compare with EPSILON tolerance over the [0, 1] bounds.
|
||||
epsilon = /*#=*/ EPSILON,
|
||||
uMin = -epsilon,
|
||||
uMax = 1 + epsilon;
|
||||
|
||||
if (infinite || (uMin < u1 && u1 < uMax && uMin < u2 && u2 < uMax)) {
|
||||
// Address the tolerance at the bounds by clipping to
|
||||
// the actual range.
|
||||
if (!infinite) {
|
||||
u1 = clamp(u1, 0, 1);
|
||||
}
|
||||
return Vec.lrp(sp, ep, u1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the nearest point on a polygon to a given point.
|
||||
*
|
||||
* @param points - Array of points defining the polygon vertices [x, y][]
|
||||
* @param point - The point to find the nearest point to [x, y]
|
||||
* @returns The nearest point on the polygon to the given point
|
||||
* @throws Error if points array is empty or has less than 2 points
|
||||
*/
|
||||
export function polygonNearestPoint(points: IVec[], point: IVec) {
|
||||
const len = points.length;
|
||||
if (len < 2) {
|
||||
throw new Error('Polygon must have at least 2 points');
|
||||
}
|
||||
|
||||
let rst: IVec = points[0]; // Initialize with first point as fallback
|
||||
let dis = Vec.dist(points[0], point);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const p = points[i];
|
||||
const p2 = points[(i + 1) % len];
|
||||
const temp = Vec.nearestPointOnLineSegment(p, p2, point, true);
|
||||
const curDis = Vec.dist(temp, point);
|
||||
if (curDis < dis) {
|
||||
dis = curDis;
|
||||
rst = temp;
|
||||
}
|
||||
}
|
||||
return rst;
|
||||
}
|
||||
|
||||
export function polygonPointDistance(points: IVec[], point: IVec) {
|
||||
const nearest = polygonNearestPoint(points, point);
|
||||
return Vec.dist(nearest, point);
|
||||
}
|
||||
|
||||
export function rotatePoints<T extends IVec>(
|
||||
points: T[],
|
||||
center: IVec,
|
||||
rotate: number
|
||||
): T[] {
|
||||
const rad = toRadian(rotate);
|
||||
return points.map(p => Vec.rotWith(p, center, rad)) as T[];
|
||||
}
|
||||
|
||||
export function rotatePoint(
|
||||
point: [number, number],
|
||||
center: IVec,
|
||||
rotate: number
|
||||
): [number, number] {
|
||||
const rad = toRadian(rotate);
|
||||
return Vec.add(center, Vec.rot(Vec.sub(point, center), rad)) as [
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
export function toRadian(angle: number) {
|
||||
return (angle * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function isPointOnLineSegment(point: IVec, line: IVec[]) {
|
||||
const [sp, ep] = line;
|
||||
const v1 = Vec.sub(point, sp);
|
||||
const v2 = Vec.sub(point, ep);
|
||||
return almostEqual(Vec.cpr(v1, v2), 0, 0.01) && Vec.dpr(v1, v2) <= 0;
|
||||
}
|
||||
|
||||
export function polygonGetPointTangent(points: IVec[], point: IVec): IVec {
|
||||
const len = points.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const p = points[i];
|
||||
const p2 = points[(i + 1) % len];
|
||||
if (isPointOnLineSegment(point, [p, p2])) {
|
||||
return Vec.normalize(Vec.sub(p2, p));
|
||||
}
|
||||
}
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
export function linePolygonIntersects(
|
||||
sp: IVec,
|
||||
ep: IVec,
|
||||
points: IVec[]
|
||||
): PointLocation[] | null {
|
||||
const result: PointLocation[] = [];
|
||||
const len = points.length;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const p = points[i];
|
||||
const p2 = points[(i + 1) % len];
|
||||
const rst = lineIntersects(sp, ep, p, p2);
|
||||
if (rst) {
|
||||
const v = new PointLocation(rst);
|
||||
v.tangent = Vec.normalize(Vec.sub(p2, p));
|
||||
result.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length ? result : null;
|
||||
}
|
||||
|
||||
export function linePolylineIntersects(
|
||||
sp: IVec,
|
||||
ep: IVec,
|
||||
points: IVec[]
|
||||
): PointLocation[] | null {
|
||||
const result: PointLocation[] = [];
|
||||
const len = points.length;
|
||||
|
||||
for (let i = 0; i < len - 1; i++) {
|
||||
const p = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const rst = lineIntersects(sp, ep, p, p2);
|
||||
if (rst) {
|
||||
result.push(new PointLocation(rst, Vec.normalize(Vec.sub(p2, p))));
|
||||
}
|
||||
}
|
||||
|
||||
return result.length ? result : null;
|
||||
}
|
||||
|
||||
export function polyLineNearestPoint(points: IVec[], point: IVec) {
|
||||
const len = points.length;
|
||||
let rst: IVec;
|
||||
let dis = Infinity;
|
||||
for (let i = 0; i < len - 1; i++) {
|
||||
const p = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const temp = Vec.nearestPointOnLineSegment(p, p2, point, true);
|
||||
const curDis = Vec.dist(temp, point);
|
||||
if (curDis < dis) {
|
||||
dis = curDis;
|
||||
rst = temp;
|
||||
}
|
||||
}
|
||||
return rst!;
|
||||
}
|
||||
|
||||
export function isPointOnlines(
|
||||
element: Bound,
|
||||
points: readonly [number, number][],
|
||||
rotate: number,
|
||||
hitPoint: [number, number],
|
||||
threshold: number
|
||||
): boolean {
|
||||
// credit to Excalidraw hitTestFreeDrawElement
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
||||
if (rotate === 0) {
|
||||
x = hitPoint[0] - element.x;
|
||||
y = hitPoint[1] - element.y;
|
||||
} else {
|
||||
// Counter-rotate the point around center before testing
|
||||
const { minX, minY, maxX, maxY } = element;
|
||||
const rotatedPoint = rotatePoint(
|
||||
hitPoint,
|
||||
[minX + (maxX - minX) / 2, minY + (maxY - minY) / 2],
|
||||
-rotate
|
||||
) as [number, number];
|
||||
x = rotatedPoint[0] - element.x;
|
||||
y = rotatedPoint[1] - element.y;
|
||||
}
|
||||
|
||||
let [A, B] = points;
|
||||
let P: readonly [number, number];
|
||||
|
||||
// For freedraw dots
|
||||
if (
|
||||
distance2d(A[0], A[1], x, y) < threshold ||
|
||||
distance2d(B[0], B[1], x, y) < threshold
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For freedraw lines
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const delta = [B[0] - A[0], B[1] - A[1]];
|
||||
const length = Math.hypot(delta[1], delta[0]);
|
||||
|
||||
const U = [delta[0] / length, delta[1] / length];
|
||||
const C = [x - A[0], y - A[1]];
|
||||
const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]);
|
||||
P = [A[0] + U[0] * d, A[1] + U[1] * d];
|
||||
|
||||
const da = distance2d(P[0], P[1], A[0], A[1]);
|
||||
const db = distance2d(P[0], P[1], B[0], B[1]);
|
||||
|
||||
P = db < da && da > length ? B : da < db && db > length ? A : P;
|
||||
|
||||
if (Math.hypot(y - P[1], x - P[0]) < threshold) {
|
||||
return true;
|
||||
}
|
||||
|
||||
A = B;
|
||||
B = points[i + 1];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
const xd = x2 - x1;
|
||||
const yd = y2 - y1;
|
||||
return Math.hypot(xd, yd);
|
||||
};
|
||||
|
||||
function square(num: number) {
|
||||
return num * num;
|
||||
}
|
||||
|
||||
function sumSqr(v: IVec, w: IVec) {
|
||||
return square(v[0] - w[0]) + square(v[1] - w[1]);
|
||||
}
|
||||
|
||||
function distToSegmentSquared(p: IVec, v: IVec, w: IVec) {
|
||||
const l2 = sumSqr(v, w);
|
||||
|
||||
if (l2 == 0) return sumSqr(p, v);
|
||||
let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2;
|
||||
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
|
||||
return sumSqr(p, [v[0] + t * (w[0] - v[0]), v[1] + t * (w[1] - v[1])]);
|
||||
}
|
||||
|
||||
function distToSegment(p: IVec, v: IVec, w: IVec) {
|
||||
return Math.sqrt(distToSegmentSquared(p, v, w));
|
||||
}
|
||||
|
||||
export function isPointIn(a: IBound, x: number, y: number): boolean {
|
||||
return a.x <= x && x <= a.x + a.w && a.y <= y && y <= a.y + a.h;
|
||||
}
|
||||
|
||||
export function intersects(a: IBound, b: IBound): boolean {
|
||||
return (
|
||||
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
||||
);
|
||||
}
|
||||
|
||||
export function almostEqual(a: number, b: number, epsilon = 0.0001) {
|
||||
return Math.abs(a - b) < epsilon;
|
||||
}
|
||||
|
||||
export function isVecZero(v: IVec) {
|
||||
return v.every(n => isZero(n));
|
||||
}
|
||||
|
||||
export function isZero(x: number) {
|
||||
return x >= -EPSILON && x <= EPSILON;
|
||||
}
|
||||
|
||||
export function pointAlmostEqual(a: IVec, b: IVec, _epsilon = 0.0001) {
|
||||
return a.length === b.length && a.every((v, i) => almostEqual(v, b[i]));
|
||||
}
|
||||
|
||||
export function clamp(n: number, min: number, max?: number): number {
|
||||
return Math.max(min, max !== undefined ? Math.min(n, max) : n);
|
||||
}
|
||||
|
||||
export function pointInEllipse(
|
||||
A: IVec,
|
||||
C: IVec,
|
||||
rx: number,
|
||||
ry: number,
|
||||
rotation = 0
|
||||
): boolean {
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
const delta = Vec.sub(A, C);
|
||||
const tdx = cos * delta[0] + sin * delta[1];
|
||||
const tdy = sin * delta[0] - cos * delta[1];
|
||||
|
||||
return (tdx * tdx) / (rx * rx) + (tdy * tdy) / (ry * ry) <= 1;
|
||||
}
|
||||
|
||||
export function pointInPolygon(p: IVec, points: IVec[]): boolean {
|
||||
let wn = 0; // winding number
|
||||
|
||||
points.forEach((a, i) => {
|
||||
const b = points[(i + 1) % points.length];
|
||||
if (a[1] <= p[1]) {
|
||||
if (b[1] > p[1] && Vec.cross(a, b, p) > 0) {
|
||||
wn += 1;
|
||||
}
|
||||
} else if (b[1] <= p[1] && Vec.cross(a, b, p) < 0) {
|
||||
wn -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
return wn !== 0;
|
||||
}
|
||||
|
||||
export function pointOnEllipse(
|
||||
point: IVec,
|
||||
rx: number,
|
||||
ry: number,
|
||||
threshold: number
|
||||
): boolean {
|
||||
// slope of point
|
||||
const t = point[1] / point[0];
|
||||
const squaredX =
|
||||
(square(rx) * square(ry)) / (square(rx) * square(t) + square(ry));
|
||||
const squaredY =
|
||||
(square(rx) * square(ry) - square(ry) * squaredX) / square(rx);
|
||||
|
||||
return (
|
||||
Math.abs(
|
||||
Math.sqrt(square(point[1]) + square(point[0])) -
|
||||
Math.sqrt(squaredX + squaredY)
|
||||
) < threshold
|
||||
);
|
||||
}
|
||||
|
||||
export function pointOnPolygonStoke(
|
||||
p: IVec,
|
||||
points: IVec[],
|
||||
threshold: number
|
||||
): boolean {
|
||||
for (let i = 0; i < points.length; ++i) {
|
||||
const next = i + 1 === points.length ? 0 : i + 1;
|
||||
if (distToSegment(p, points[i], points[next]) <= threshold) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getPolygonPathFromPoints(
|
||||
points: IVec[],
|
||||
closed = true
|
||||
): string {
|
||||
const len = points.length;
|
||||
if (len < 2) return ``;
|
||||
|
||||
const a = points[0];
|
||||
const b = points[1];
|
||||
|
||||
let res = `M${a[0].toFixed(2)},${a[1].toFixed()}L${b[0].toFixed(2)},${b[1].toFixed()}`;
|
||||
|
||||
for (let i = 2; i < len; i++) {
|
||||
const a = points[i];
|
||||
res += `L${a[0].toFixed(2)},${a[1].toFixed()}`;
|
||||
}
|
||||
|
||||
if (closed) res += 'Z';
|
||||
return res;
|
||||
}
|
||||
export function getSvgPathFromStroke(points: IVec[], closed = true): string {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 4) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
const c = points[2];
|
||||
|
||||
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
|
||||
2
|
||||
)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
|
||||
b[1],
|
||||
c[1]
|
||||
).toFixed(2)} T`;
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
|
||||
2
|
||||
)} `;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += 'Z';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function average(a: number, b: number): number {
|
||||
return (a + b) / 2;
|
||||
}
|
||||
|
||||
//reference https://www.xarg.org/book/computer-graphics/line-segment-ellipse-intersection/
|
||||
export function lineEllipseIntersects(
|
||||
A: IVec,
|
||||
B: IVec,
|
||||
C: IVec,
|
||||
rx: number,
|
||||
ry: number,
|
||||
rad = 0
|
||||
) {
|
||||
A = Vec.rot(Vec.sub(A, C), -rad);
|
||||
B = Vec.rot(Vec.sub(B, C), -rad);
|
||||
|
||||
rx *= rx;
|
||||
ry *= ry;
|
||||
|
||||
const rst: IVec[] = [];
|
||||
|
||||
const v = Vec.sub(B, A);
|
||||
|
||||
const a = rx * v[1] * v[1] + ry * v[0] * v[0];
|
||||
const b = 2 * (rx * A[1] * v[1] + ry * A[0] * v[0]);
|
||||
const c = rx * A[1] * A[1] + ry * A[0] * A[0] - rx * ry;
|
||||
|
||||
const D = b * b - 4 * a * c; // Discriminant
|
||||
|
||||
if (D >= 0) {
|
||||
const sqrtD = Math.sqrt(D);
|
||||
const t1 = (-b + sqrtD) / (2 * a);
|
||||
const t2 = (-b - sqrtD) / (2 * a);
|
||||
|
||||
if (0 <= t1 && t1 <= 1)
|
||||
rst.push(Vec.add(Vec.rot(Vec.add(Vec.mul(v, t1), A), rad), C));
|
||||
|
||||
if (0 <= t2 && t2 <= 1 && Math.abs(t1 - t2) > 1e-16)
|
||||
rst.push(Vec.add(Vec.rot(Vec.add(Vec.mul(v, t2), A), rad), C));
|
||||
}
|
||||
|
||||
if (rst.length === 0) return null;
|
||||
|
||||
return rst.map(v => {
|
||||
const pl = new PointLocation(v);
|
||||
const normalVector = Vec.uni(Vec.divV(Vec.sub(v, C), [rx * rx, ry * ry]));
|
||||
pl.tangent = [-normalVector[1], normalVector[0]];
|
||||
return pl;
|
||||
});
|
||||
}
|
||||
|
||||
export function sign(number: number) {
|
||||
return number > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
export function getPointFromBoundsWithRotation(
|
||||
bounds: IBound,
|
||||
point: IVec
|
||||
): IVec {
|
||||
const { x, y, w, h, rotate } = bounds;
|
||||
|
||||
if (!rotate) return point;
|
||||
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
const m = new DOMMatrix()
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-cx, -cy);
|
||||
|
||||
const p = new DOMPoint(...point).matrixTransform(m);
|
||||
return [p.x, p.y];
|
||||
}
|
||||
|
||||
export function normalizeDegAngle(angle: number) {
|
||||
if (angle < 0) angle += 360;
|
||||
angle %= 360;
|
||||
return angle;
|
||||
}
|
||||
|
||||
export function toDegree(radian: number) {
|
||||
return (radian * 180) / Math.PI;
|
||||
}
|
||||
|
||||
// 0 means x axis, 1 means y axis
|
||||
export function isOverlap(
|
||||
line1: IVec[],
|
||||
line2: IVec[],
|
||||
axis: 0 | 1,
|
||||
strict = true
|
||||
) {
|
||||
const less = strict
|
||||
? (a: number, b: number) => a < b
|
||||
: (a: number, b: number) => a <= b;
|
||||
return !(
|
||||
less(
|
||||
Math.max(line1[0][axis], line1[1][axis]),
|
||||
Math.min(line2[0][axis], line2[1][axis])
|
||||
) ||
|
||||
less(
|
||||
Math.max(line2[0][axis], line2[1][axis]),
|
||||
Math.min(line1[0][axis], line1[1][axis])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCenterAreaBounds(bounds: IBound, ratio: number) {
|
||||
const { x, y, w, h, rotate } = bounds;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const nw = w * ratio;
|
||||
const nh = h * ratio;
|
||||
return {
|
||||
x: cx - nw / 2,
|
||||
y: cy - nh / 2,
|
||||
w: nw,
|
||||
h: nh,
|
||||
rotate,
|
||||
};
|
||||
}
|
||||
373
blocksuite/framework/global/src/gfx/model/bound.ts
Normal file
373
blocksuite/framework/global/src/gfx/model/bound.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { EPSILON, lineIntersects, polygonPointDistance } from '../math.js';
|
||||
import type { SerializedXYWH, XYWH } from '../xywh.js';
|
||||
import { deserializeXYWH, serializeXYWH } from '../xywh.js';
|
||||
import { type IVec, Vec } from './vec.js';
|
||||
|
||||
export function getIBoundFromPoints(
|
||||
points: IVec[],
|
||||
rotation = 0
|
||||
): IBound & {
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
minX: number;
|
||||
minY: number;
|
||||
} {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
if (points.length < 1) {
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
maxX = 1;
|
||||
maxY = 1;
|
||||
} else {
|
||||
for (const [x, y] of points) {
|
||||
minX = Math.min(x, minX);
|
||||
minY = Math.min(y, minY);
|
||||
maxX = Math.max(x, maxX);
|
||||
maxY = Math.max(y, maxY);
|
||||
}
|
||||
}
|
||||
|
||||
if (rotation !== 0) {
|
||||
return getIBoundFromPoints(
|
||||
points.map(pt =>
|
||||
Vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], rotation)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
x: minX,
|
||||
y: minY,
|
||||
w: maxX - minX,
|
||||
h: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the x, y, width, and height of a block that can be easily accessed.
|
||||
*/
|
||||
export interface IBound {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rotate?: number;
|
||||
}
|
||||
|
||||
export class Bound implements IBound {
|
||||
h: number;
|
||||
|
||||
w: number;
|
||||
|
||||
x: number;
|
||||
|
||||
y: number;
|
||||
|
||||
get bl(): IVec {
|
||||
return [this.x, this.y + this.h];
|
||||
}
|
||||
|
||||
get br(): IVec {
|
||||
return [this.x + this.w, this.y + this.h];
|
||||
}
|
||||
|
||||
get center(): IVec {
|
||||
return [this.x + this.w / 2, this.y + this.h / 2];
|
||||
}
|
||||
|
||||
set center([cx, cy]: IVec) {
|
||||
const [px, py] = this.center;
|
||||
this.x += cx - px;
|
||||
this.y += cy - py;
|
||||
}
|
||||
|
||||
get horizontalLine(): IVec[] {
|
||||
return [
|
||||
[this.x, this.y + this.h / 2],
|
||||
[this.x + this.w, this.y + this.h / 2],
|
||||
];
|
||||
}
|
||||
|
||||
get leftLine(): IVec[] {
|
||||
return [
|
||||
[this.x, this.y],
|
||||
[this.x, this.y + this.h],
|
||||
];
|
||||
}
|
||||
|
||||
get lowerLine(): IVec[] {
|
||||
return [
|
||||
[this.x, this.y + this.h],
|
||||
[this.x + this.w, this.y + this.h],
|
||||
];
|
||||
}
|
||||
|
||||
get maxX() {
|
||||
return this.x + this.w;
|
||||
}
|
||||
|
||||
get maxY() {
|
||||
return this.y + this.h;
|
||||
}
|
||||
|
||||
get midPoints(): IVec[] {
|
||||
return [
|
||||
[this.x + this.w / 2, this.y],
|
||||
[this.x + this.w, this.y + this.h / 2],
|
||||
[this.x + this.w / 2, this.y + this.h],
|
||||
[this.x, this.y + this.h / 2],
|
||||
];
|
||||
}
|
||||
|
||||
get minX() {
|
||||
return this.x;
|
||||
}
|
||||
|
||||
get minY() {
|
||||
return this.y;
|
||||
}
|
||||
|
||||
get points(): IVec[] {
|
||||
return [
|
||||
[this.x, this.y],
|
||||
[this.x + this.w, this.y],
|
||||
[this.x + this.w, this.y + this.h],
|
||||
[this.x, this.y + this.h],
|
||||
];
|
||||
}
|
||||
|
||||
get rightLine(): IVec[] {
|
||||
return [
|
||||
[this.x + this.w, this.y],
|
||||
[this.x + this.w, this.y + this.h],
|
||||
];
|
||||
}
|
||||
|
||||
get tl(): IVec {
|
||||
return [this.x, this.y];
|
||||
}
|
||||
|
||||
get tr(): IVec {
|
||||
return [this.x + this.w, this.y];
|
||||
}
|
||||
|
||||
get upperLine(): IVec[] {
|
||||
return [
|
||||
[this.x, this.y],
|
||||
[this.x + this.w, this.y],
|
||||
];
|
||||
}
|
||||
|
||||
get verticalLine(): IVec[] {
|
||||
return [
|
||||
[this.x + this.w / 2, this.y],
|
||||
[this.x + this.w / 2, this.y + this.h],
|
||||
];
|
||||
}
|
||||
|
||||
constructor(x = 0, y = 0, w = 0, h = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
}
|
||||
|
||||
static deserialize(s: string) {
|
||||
const [x, y, w, h] = deserializeXYWH(s);
|
||||
return new Bound(x, y, w, h);
|
||||
}
|
||||
|
||||
static from(arg1: IBound) {
|
||||
return new Bound(arg1.x, arg1.y, arg1.w, arg1.h);
|
||||
}
|
||||
|
||||
static fromCenter(center: IVec, width: number, height: number) {
|
||||
const [x, y] = center;
|
||||
return new Bound(x - width / 2, y - height / 2, width, height);
|
||||
}
|
||||
|
||||
static fromDOMRect({ left, top, width, height }: DOMRect) {
|
||||
return new Bound(left, top, width, height);
|
||||
}
|
||||
|
||||
static fromPoints(points: IVec[]) {
|
||||
return Bound.from(getIBoundFromPoints(points));
|
||||
}
|
||||
|
||||
static fromXYWH(xywh: XYWH) {
|
||||
return new Bound(xywh[0], xywh[1], xywh[2], xywh[3]);
|
||||
}
|
||||
|
||||
static serialize(bound: IBound) {
|
||||
return serializeXYWH(bound.x, bound.y, bound.w, bound.h);
|
||||
}
|
||||
|
||||
clone(): Bound {
|
||||
return new Bound(this.x, this.y, this.w, this.h);
|
||||
}
|
||||
|
||||
contains(bound: Bound) {
|
||||
return (
|
||||
bound.x >= this.x &&
|
||||
bound.y >= this.y &&
|
||||
bound.maxX <= this.maxX &&
|
||||
bound.maxY <= this.maxY
|
||||
);
|
||||
}
|
||||
|
||||
containsPoint([x, y]: IVec): boolean {
|
||||
const { minX, minY, maxX, maxY } = this;
|
||||
return minX <= x && x <= maxX && minY <= y && y <= maxY;
|
||||
}
|
||||
|
||||
expand(margin: [number, number]): Bound;
|
||||
expand(left: number, top?: number, right?: number, bottom?: number): Bound;
|
||||
expand(
|
||||
left: number | [number, number],
|
||||
top?: number,
|
||||
right?: number,
|
||||
bottom?: number
|
||||
) {
|
||||
if (Array.isArray(left)) {
|
||||
const [x, y] = left;
|
||||
return new Bound(this.x - x, this.y - y, this.w + x * 2, this.h + y * 2);
|
||||
}
|
||||
|
||||
top ??= left;
|
||||
right ??= left;
|
||||
bottom ??= top;
|
||||
|
||||
return new Bound(
|
||||
this.x - left,
|
||||
this.y - top,
|
||||
this.w + left + right,
|
||||
this.h + top + bottom
|
||||
);
|
||||
}
|
||||
|
||||
getRelativePoint([x, y]: IVec): IVec {
|
||||
return [this.x + x * this.w, this.y + y * this.h];
|
||||
}
|
||||
|
||||
getVerticesAndMidpoints() {
|
||||
return [...this.points, ...this.midPoints];
|
||||
}
|
||||
|
||||
horizontalDistance(bound: Bound) {
|
||||
return Math.min(
|
||||
Math.abs(this.minX - bound.maxX),
|
||||
Math.abs(this.maxX - bound.minX)
|
||||
);
|
||||
}
|
||||
|
||||
include(point: IVec) {
|
||||
const x1 = Math.min(this.x, point[0]),
|
||||
y1 = Math.min(this.y, point[1]),
|
||||
x2 = Math.max(this.maxX, point[0]),
|
||||
y2 = Math.max(this.maxY, point[1]);
|
||||
return new Bound(x1, y1, x2 - x1, y2 - y1);
|
||||
}
|
||||
|
||||
intersectLine(sp: IVec, ep: IVec, infinite = false) {
|
||||
const rst: IVec[] = [];
|
||||
(
|
||||
[
|
||||
[this.tl, this.tr],
|
||||
[this.tl, this.bl],
|
||||
[this.tr, this.br],
|
||||
[this.bl, this.br],
|
||||
] as IVec[][]
|
||||
).forEach(([p1, p2]) => {
|
||||
const p = lineIntersects(sp, ep, p1, p2, infinite);
|
||||
if (p) rst.push(p);
|
||||
});
|
||||
return rst.length === 0 ? null : rst;
|
||||
}
|
||||
|
||||
isHorizontalCross(bound: Bound) {
|
||||
return !(this.maxY < bound.minY || this.minY > bound.maxY);
|
||||
}
|
||||
|
||||
isIntersectWithBound(bound: Bound, epsilon = EPSILON) {
|
||||
return (
|
||||
bound.maxX > this.minX - epsilon &&
|
||||
bound.maxY > this.minY - epsilon &&
|
||||
bound.minX < this.maxX + epsilon &&
|
||||
bound.minY < this.maxY + epsilon &&
|
||||
!this.contains(bound) &&
|
||||
!bound.contains(this)
|
||||
);
|
||||
}
|
||||
|
||||
isOverlapWithBound(bound: Bound, epsilon = EPSILON) {
|
||||
return (
|
||||
bound.maxX > this.minX - epsilon &&
|
||||
bound.maxY > this.minY - epsilon &&
|
||||
bound.minX < this.maxX + epsilon &&
|
||||
bound.minY < this.maxY + epsilon
|
||||
);
|
||||
}
|
||||
|
||||
isPointInBound([x, y]: IVec, tolerance = 0.01) {
|
||||
return (
|
||||
x > this.minX + tolerance &&
|
||||
x < this.maxX - tolerance &&
|
||||
y > this.minY + tolerance &&
|
||||
y < this.maxY - tolerance
|
||||
);
|
||||
}
|
||||
|
||||
isPointNearBound([x, y]: IVec, tolerance = 0.01) {
|
||||
return polygonPointDistance(this.points, [x, y]) < tolerance;
|
||||
}
|
||||
|
||||
isVerticalCross(bound: Bound) {
|
||||
return !(this.maxX < bound.minX || this.minX > bound.maxX);
|
||||
}
|
||||
|
||||
moveDelta(dx: number, dy: number) {
|
||||
return new Bound(this.x + dx, this.y + dy, this.w, this.h);
|
||||
}
|
||||
|
||||
serialize(): SerializedXYWH {
|
||||
return serializeXYWH(this.x, this.y, this.w, this.h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a point to relative coordinates.
|
||||
* @param point - The point to convert.
|
||||
* @returns The normalized relative coordinates of the point.
|
||||
*/
|
||||
toRelative([x, y]: IVec): IVec {
|
||||
const normalizedX = this.w === 0 ? 0 : (x - this.x) / this.w;
|
||||
const normalizedY = this.h === 0 ? 0 : (y - this.y) / this.h;
|
||||
return [normalizedX, normalizedY];
|
||||
}
|
||||
|
||||
toXYWH(): XYWH {
|
||||
return [this.x, this.y, this.w, this.h];
|
||||
}
|
||||
|
||||
unite(bound: Bound) {
|
||||
const x1 = Math.min(this.x, bound.x),
|
||||
y1 = Math.min(this.y, bound.y),
|
||||
x2 = Math.max(this.maxX, bound.maxX),
|
||||
y2 = Math.max(this.maxY, bound.maxY);
|
||||
return new Bound(x1, y1, x2 - x1, y2 - y1);
|
||||
}
|
||||
|
||||
verticalDistance(bound: Bound) {
|
||||
return Math.min(
|
||||
Math.abs(this.minY - bound.maxY),
|
||||
Math.abs(this.maxY - bound.minY)
|
||||
);
|
||||
}
|
||||
}
|
||||
4
blocksuite/framework/global/src/gfx/model/index.ts
Normal file
4
blocksuite/framework/global/src/gfx/model/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './bound.js';
|
||||
export * from './point.js';
|
||||
export * from './point-location.js';
|
||||
export * from './vec.js';
|
||||
94
blocksuite/framework/global/src/gfx/model/point-location.ts
Normal file
94
blocksuite/framework/global/src/gfx/model/point-location.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { type IVec, Vec } from './vec.js';
|
||||
|
||||
/**
|
||||
* PointLocation is an implementation of IVec with in/out vectors and tangent.
|
||||
* This is useful when dealing with path.
|
||||
*/
|
||||
export class PointLocation extends Array<number> implements IVec {
|
||||
_in: IVec = [0, 0];
|
||||
|
||||
_out: IVec = [0, 0];
|
||||
|
||||
// the tangent belongs to the point on the element outline
|
||||
_tangent: IVec = [0, 0];
|
||||
|
||||
[0]: number;
|
||||
|
||||
[1]: number;
|
||||
|
||||
get absIn() {
|
||||
return Vec.add(this, this._in);
|
||||
}
|
||||
|
||||
get absOut() {
|
||||
return Vec.add(this, this._out);
|
||||
}
|
||||
|
||||
get in() {
|
||||
return this._in;
|
||||
}
|
||||
|
||||
set in(value: IVec) {
|
||||
this._in = value;
|
||||
}
|
||||
|
||||
override get length() {
|
||||
return super.length as 2;
|
||||
}
|
||||
|
||||
get out() {
|
||||
return this._out;
|
||||
}
|
||||
|
||||
set out(value: IVec) {
|
||||
this._out = value;
|
||||
}
|
||||
|
||||
get tangent() {
|
||||
return this._tangent;
|
||||
}
|
||||
|
||||
set tangent(value: IVec) {
|
||||
this._tangent = value;
|
||||
}
|
||||
|
||||
constructor(
|
||||
point: IVec = [0, 0],
|
||||
tangent: IVec = [0, 0],
|
||||
inVec: IVec = [0, 0],
|
||||
outVec: IVec = [0, 0]
|
||||
) {
|
||||
super(2);
|
||||
this[0] = point[0];
|
||||
this[1] = point[1];
|
||||
this._tangent = tangent;
|
||||
this._in = inVec;
|
||||
this._out = outVec;
|
||||
}
|
||||
|
||||
static fromVec(vec: IVec) {
|
||||
const point = new PointLocation();
|
||||
point[0] = vec[0];
|
||||
point[1] = vec[1];
|
||||
return point;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new PointLocation(
|
||||
this as unknown as IVec,
|
||||
this._tangent,
|
||||
this._in,
|
||||
this._out
|
||||
);
|
||||
}
|
||||
|
||||
setVec(vec: IVec) {
|
||||
this[0] = vec[0];
|
||||
this[1] = vec[1];
|
||||
return this;
|
||||
}
|
||||
|
||||
toVec(): IVec {
|
||||
return [this[0], this[1]];
|
||||
}
|
||||
}
|
||||
268
blocksuite/framework/global/src/gfx/model/point.ts
Normal file
268
blocksuite/framework/global/src/gfx/model/point.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { clamp } from '../math.js';
|
||||
|
||||
export interface IPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export class Point {
|
||||
x: number;
|
||||
|
||||
y: number;
|
||||
|
||||
constructor(x = 0, y = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a value to a certain interval.
|
||||
*/
|
||||
static clamp(p: Point, min: Point, max: Point) {
|
||||
return new Point(clamp(p.x, min.x, max.x), clamp(p.y, min.y, max.y));
|
||||
}
|
||||
|
||||
static from(point: IPoint | number[] | number, y?: number) {
|
||||
if (Array.isArray(point)) {
|
||||
return new Point(point[0], point[1]);
|
||||
}
|
||||
if (typeof point === 'number') {
|
||||
return new Point(point, y ?? point);
|
||||
}
|
||||
return new Point(point.x, point.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares and returns the maximum of two points.
|
||||
*/
|
||||
static max(a: Point, b: Point) {
|
||||
return new Point(Math.max(a.x, b.x), Math.max(a.y, b.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares and returns the minimum of two points.
|
||||
*/
|
||||
static min(a: Point, b: Point) {
|
||||
return new Point(Math.min(a.x, b.x), Math.min(a.y, b.y));
|
||||
}
|
||||
|
||||
add(point: IPoint): Point {
|
||||
return new Point(this.x + point.x, this.y + point.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the point.
|
||||
*/
|
||||
clone() {
|
||||
return new Point(this.x, this.y);
|
||||
}
|
||||
|
||||
cross(point: IPoint): number {
|
||||
return this.x * point.y - this.y * point.x;
|
||||
}
|
||||
|
||||
equals({ x, y }: Point) {
|
||||
return this.x === x && this.y === y;
|
||||
}
|
||||
|
||||
lerp(point: IPoint, t: number): Point {
|
||||
return new Point(
|
||||
this.x + (point.x - this.x) * t,
|
||||
this.y + (point.y - this.y) * t
|
||||
);
|
||||
}
|
||||
|
||||
scale(factor: number): Point {
|
||||
return new Point(this.x * factor, this.y * factor);
|
||||
}
|
||||
|
||||
set(x: number, y: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
subtract(point: IPoint): Point {
|
||||
return new Point(this.x - point.x, this.y - point.y);
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return [this.x, this.y];
|
||||
}
|
||||
}
|
||||
|
||||
export class Rect {
|
||||
// `[right, bottom]`
|
||||
max: Point;
|
||||
|
||||
// `[left, top]`
|
||||
min: Point;
|
||||
|
||||
get bottom() {
|
||||
return this.max.y;
|
||||
}
|
||||
|
||||
set bottom(y: number) {
|
||||
this.max.y = y;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.max.y - this.min.y;
|
||||
}
|
||||
|
||||
set height(h: number) {
|
||||
this.max.y = this.min.y + h;
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this.min.x;
|
||||
}
|
||||
|
||||
set left(x: number) {
|
||||
this.min.x = x;
|
||||
}
|
||||
|
||||
get right() {
|
||||
return this.max.x;
|
||||
}
|
||||
|
||||
set right(x: number) {
|
||||
this.max.x = x;
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this.min.y;
|
||||
}
|
||||
|
||||
set top(y: number) {
|
||||
this.min.y = y;
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.max.x - this.min.x;
|
||||
}
|
||||
|
||||
set width(w: number) {
|
||||
this.max.x = this.min.x + w;
|
||||
}
|
||||
|
||||
constructor(left: number, top: number, right: number, bottom: number) {
|
||||
const [minX, maxX] = left <= right ? [left, right] : [right, left];
|
||||
const [minY, maxY] = top <= bottom ? [top, bottom] : [bottom, top];
|
||||
this.min = new Point(minX, minY);
|
||||
this.max = new Point(maxX, maxY);
|
||||
}
|
||||
|
||||
static fromDOM(dom: Element) {
|
||||
return Rect.fromDOMRect(dom.getBoundingClientRect());
|
||||
}
|
||||
|
||||
static fromDOMRect({ left, top, right, bottom }: DOMRect) {
|
||||
return Rect.fromLTRB(left, top, right, bottom);
|
||||
}
|
||||
|
||||
static fromLTRB(left: number, top: number, right: number, bottom: number) {
|
||||
return new Rect(left, top, right, bottom);
|
||||
}
|
||||
|
||||
static fromLWTH(left: number, width: number, top: number, height: number) {
|
||||
return new Rect(left, top, left + width, top + height);
|
||||
}
|
||||
|
||||
static fromPoint(point: Point) {
|
||||
return Rect.fromPoints(point.clone(), point);
|
||||
}
|
||||
|
||||
static fromPoints(start: Point, end: Point) {
|
||||
const width = Math.abs(end.x - start.x);
|
||||
const height = Math.abs(end.y - start.y);
|
||||
const left = Math.min(end.x, start.x);
|
||||
const top = Math.min(end.y, start.y);
|
||||
return Rect.fromLWTH(left, width, top, height);
|
||||
}
|
||||
|
||||
static fromXY(x: number, y: number) {
|
||||
return Rect.fromPoint(new Point(x, y));
|
||||
}
|
||||
|
||||
center() {
|
||||
return new Point(
|
||||
(this.left + this.right) / 2,
|
||||
(this.top + this.bottom) / 2
|
||||
);
|
||||
}
|
||||
|
||||
clamp(p: Point) {
|
||||
return Point.clamp(p, this.min, this.max);
|
||||
}
|
||||
|
||||
clone() {
|
||||
const { left, top, right, bottom } = this;
|
||||
return new Rect(left, top, right, bottom);
|
||||
}
|
||||
|
||||
contains({ min, max }: Rect) {
|
||||
return this.isPointIn(min) && this.isPointIn(max);
|
||||
}
|
||||
|
||||
equals({ min, max }: Rect) {
|
||||
return this.min.equals(min) && this.max.equals(max);
|
||||
}
|
||||
|
||||
extend_with(point: Point) {
|
||||
this.min = Point.min(this.min, point);
|
||||
this.max = Point.max(this.max, point);
|
||||
}
|
||||
|
||||
extend_with_x(x: number) {
|
||||
this.min.x = Math.min(this.min.x, x);
|
||||
this.max.x = Math.max(this.max.x, x);
|
||||
}
|
||||
|
||||
extend_with_y(y: number) {
|
||||
this.min.y = Math.min(this.min.y, y);
|
||||
this.max.y = Math.max(this.max.y, y);
|
||||
}
|
||||
|
||||
intersect(other: Rect) {
|
||||
return Rect.fromPoints(
|
||||
Point.max(this.min, other.min),
|
||||
Point.min(this.max, other.max)
|
||||
);
|
||||
}
|
||||
|
||||
intersects({ left, top, right, bottom }: Rect) {
|
||||
return (
|
||||
this.left <= right &&
|
||||
left <= this.right &&
|
||||
this.top <= bottom &&
|
||||
top <= this.bottom
|
||||
);
|
||||
}
|
||||
|
||||
isPointDown({ x, y }: Point) {
|
||||
return this.bottom < y && this.left <= x && this.right >= x;
|
||||
}
|
||||
|
||||
isPointIn({ x, y }: Point) {
|
||||
return (
|
||||
this.left <= x && x <= this.right && this.top <= y && y <= this.bottom
|
||||
);
|
||||
}
|
||||
|
||||
isPointLeft({ x, y }: Point) {
|
||||
return x < this.left && this.top <= y && this.bottom >= y;
|
||||
}
|
||||
|
||||
isPointRight({ x, y }: Point) {
|
||||
return x > this.right && this.top <= y && this.bottom >= y;
|
||||
}
|
||||
|
||||
isPointUp({ x, y }: Point) {
|
||||
return y < this.top && this.left <= x && this.right >= x;
|
||||
}
|
||||
|
||||
toDOMRect() {
|
||||
const { left, top, width, height } = this;
|
||||
return new DOMRect(left, top, width, height);
|
||||
}
|
||||
}
|
||||
601
blocksuite/framework/global/src/gfx/model/vec.ts
Normal file
601
blocksuite/framework/global/src/gfx/model/vec.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
// Inlined from https://raw.githubusercontent.com/tldraw/tldraw/24cad6959f59f93e20e556d018c391fd89d4ecca/packages/vec/src/index.ts
|
||||
// Credits to tldraw
|
||||
|
||||
export type IVec = [number, number];
|
||||
|
||||
export type IVec3 = [number, number, number];
|
||||
|
||||
export class Vec {
|
||||
/**
|
||||
* Absolute value of a vector.
|
||||
* @param A
|
||||
* @returns
|
||||
*/
|
||||
static abs = (A: number[]): number[] => {
|
||||
return [Math.abs(A[0]), Math.abs(A[1])];
|
||||
};
|
||||
|
||||
/**
|
||||
* Add vectors.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static add = (A: number[], B: number[]): IVec => {
|
||||
return [A[0] + B[0], A[1] + B[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Add scalar to vector.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static addScalar = (A: number[], n: number): IVec => {
|
||||
return [A[0] + n, A[1] + n];
|
||||
};
|
||||
|
||||
/**
|
||||
* Angle between vector A and vector B in radians
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static ang = (A: number[], B: number[]): number => {
|
||||
return Math.atan2(Vec.cpr(A, B), Vec.dpr(A, B));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the angle between the three vectors A, B, and C.
|
||||
* @param p1
|
||||
* @param pc
|
||||
* @param p2
|
||||
*/
|
||||
static ang3 = (p1: IVec, pc: IVec, p2: IVec): number => {
|
||||
// this,
|
||||
const v1 = Vec.vec(pc, p1);
|
||||
const v2 = Vec.vec(pc, p2);
|
||||
return Vec.ang(v1, v2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Angle between vector A and vector B in radians
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static angle = (A: IVec, B: IVec): number => {
|
||||
return Math.atan2(B[1] - A[1], B[0] - A[0]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get whether p1 is left of p2, relative to pc.
|
||||
* @param p1
|
||||
* @param pc
|
||||
* @param p2
|
||||
*/
|
||||
static clockwise = (p1: number[], pc: number[], p2: number[]): boolean => {
|
||||
return Vec.isLeft(p1, pc, p2) > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cross product (outer product) | A X B |
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static cpr = (A: number[], B: number[]): number => {
|
||||
return A[0] * B[1] - B[0] * A[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Dist length from A to B
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static dist = (A: number[], B: number[]): number => {
|
||||
return Math.hypot(A[1] - B[1], A[0] - B[0]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dist length from A to B squared.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static dist2 = (A: IVec, B: IVec): number => {
|
||||
return Vec.len2(Vec.sub(A, B));
|
||||
};
|
||||
|
||||
/**
|
||||
* Distance between a point and the nearest point on a bounding box.
|
||||
* @param bounds The bounding box.
|
||||
* @param P The point
|
||||
* @returns
|
||||
*/
|
||||
static distanceToBounds = (
|
||||
bounds: {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
},
|
||||
P: number[]
|
||||
): number => {
|
||||
return Vec.dist(P, Vec.nearestPointOnBounds(bounds, P));
|
||||
};
|
||||
|
||||
/**
|
||||
* Distance between a point and the nearest point on a line segment between A and B
|
||||
* @param A The start of the line segment
|
||||
* @param B The end of the line segment
|
||||
* @param P The off-line point
|
||||
* @param clamp Whether to clamp the point between A and B.
|
||||
* @returns
|
||||
*/
|
||||
static distanceToLineSegment = (
|
||||
A: IVec,
|
||||
B: IVec,
|
||||
P: IVec,
|
||||
clamp = true
|
||||
): number => {
|
||||
return Vec.dist(P, Vec.nearestPointOnLineSegment(A, B, P, clamp));
|
||||
};
|
||||
|
||||
/**
|
||||
* Distance between a point and a line with a known unit vector that passes through a point.
|
||||
* @param A Any point on the line
|
||||
* @param u The unit vector for the line.
|
||||
* @param P A point not on the line to test.
|
||||
* @returns
|
||||
*/
|
||||
static distanceToLineThroughPoint = (A: IVec, u: IVec, P: IVec): number => {
|
||||
return Vec.dist(P, Vec.nearestPointOnLineThroughPoint(A, u, P));
|
||||
};
|
||||
|
||||
/**
|
||||
* Vector division by scalar.
|
||||
* @param A
|
||||
* @param n
|
||||
*/
|
||||
static div = (A: IVec, n: number): IVec => {
|
||||
return [A[0] / n, A[1] / n];
|
||||
};
|
||||
|
||||
/**
|
||||
* Vector division by vector.
|
||||
* @param A
|
||||
* @param n
|
||||
*/
|
||||
static divV = (A: IVec, B: IVec): IVec => {
|
||||
return [A[0] / B[0], A[1] / B[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Dot product
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static dpr = (A: number[], B: number[]): number => {
|
||||
return A[0] * B[0] + A[1] * B[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* A faster, though less accurate method for testing distances. Maybe faster?
|
||||
* @param A
|
||||
* @param B
|
||||
* @returns
|
||||
*/
|
||||
static fastDist = (A: number[], B: number[]): number[] => {
|
||||
const V = [B[0] - A[0], B[1] - A[1]];
|
||||
const aV = [Math.abs(V[0]), Math.abs(V[1])];
|
||||
let r = 1 / Math.max(aV[0], aV[1]);
|
||||
r = r * (1.29289 - (aV[0] + aV[1]) * r * 0.29289);
|
||||
return [V[0] * r, V[1] * r];
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolate from A to B when curVAL goes fromVAL: number[] => to
|
||||
* @param A
|
||||
* @param B
|
||||
* @param from Starting value
|
||||
* @param to Ending value
|
||||
* @param s Strength
|
||||
*/
|
||||
static int = (A: IVec, B: IVec, from: number, to: number, s = 1): IVec => {
|
||||
const t = (Vec.clamp(from, to) - from) / (to - from);
|
||||
return Vec.add(Vec.mul(A, 1 - t), Vec.mul(B, s));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check of two vectors are identical.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static isEqual = (A: number[], B: number[]): boolean => {
|
||||
return A[0] === B[0] && A[1] === B[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get whether p1 is left of p2, relative to pc.
|
||||
* @param p1
|
||||
* @param pc
|
||||
* @param p2
|
||||
*/
|
||||
static isLeft = (p1: number[], pc: number[], p2: number[]): number => {
|
||||
// isLeft: >0 for counterclockwise
|
||||
// =0 for none (degenerate)
|
||||
// <0 for clockwise
|
||||
return (
|
||||
(pc[0] - p1[0]) * (p2[1] - p1[1]) - (p2[0] - p1[0]) * (pc[1] - p1[1])
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Length of the vector
|
||||
* @param A
|
||||
*/
|
||||
static len = (A: number[]): number => {
|
||||
return Math.hypot(A[0], A[1]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Length of the vector squared
|
||||
* @param A
|
||||
*/
|
||||
static len2 = (A: number[]): number => {
|
||||
return A[0] * A[0] + A[1] * A[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolate vector A to B with a scalar t
|
||||
* @param A
|
||||
* @param B
|
||||
* @param t scalar
|
||||
*/
|
||||
static lrp = (A: IVec, B: IVec, t: number): IVec => {
|
||||
return Vec.add(A, Vec.mul(Vec.sub(B, A), t));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a vector comprised of the maximum of two or more vectors.
|
||||
*/
|
||||
static max = (...v: number[][]) => {
|
||||
return [Math.max(...v.map(a => a[0])), Math.max(...v.map(a => a[1]))];
|
||||
};
|
||||
|
||||
/**
|
||||
* Mean between two vectors or mid vector between two vectors
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static med = (A: IVec, B: IVec): IVec => {
|
||||
return Vec.mul(Vec.add(A, B), 0.5);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a vector comprised of the minimum of two or more vectors.
|
||||
*/
|
||||
static min = (...v: number[][]) => {
|
||||
return [Math.min(...v.map(a => a[0])), Math.min(...v.map(a => a[1]))];
|
||||
};
|
||||
|
||||
/**
|
||||
* Vector multiplication by scalar
|
||||
* @param A
|
||||
* @param n
|
||||
*/
|
||||
static mul = (A: IVec, n: number): IVec => {
|
||||
return [A[0] * n, A[1] * n];
|
||||
};
|
||||
|
||||
/**
|
||||
* Multiple two vectors.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static mulV = (A: IVec, B: IVec): IVec => {
|
||||
return [A[0] * B[0], A[1] * B[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the nearest point on a bounding box to a point P.
|
||||
* @param bounds The bounding box
|
||||
* @param P The point point
|
||||
* @returns
|
||||
*/
|
||||
static nearestPointOnBounds = (
|
||||
bounds: {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
},
|
||||
P: number[]
|
||||
): number[] => {
|
||||
return [
|
||||
Vec.clamp(P[0], bounds.minX, bounds.maxX),
|
||||
Vec.clamp(P[1], bounds.minY, bounds.maxY),
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the nearest point on a line segment between A and B
|
||||
* @param A The start of the line segment
|
||||
* @param B The end of the line segment
|
||||
* @param P The off-line point
|
||||
* @param clamp Whether to clamp the point between A and B.
|
||||
* @returns
|
||||
*/
|
||||
static nearestPointOnLineSegment = (
|
||||
A: IVec,
|
||||
B: IVec,
|
||||
P: IVec,
|
||||
clamp = true
|
||||
): IVec => {
|
||||
const u = Vec.uni(Vec.sub(B, A));
|
||||
const C = Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u)));
|
||||
|
||||
if (clamp) {
|
||||
if (C[0] < Math.min(A[0], B[0])) return A[0] < B[0] ? A : B;
|
||||
if (C[0] > Math.max(A[0], B[0])) return A[0] > B[0] ? A : B;
|
||||
if (C[1] < Math.min(A[1], B[1])) return A[1] < B[1] ? A : B;
|
||||
if (C[1] > Math.max(A[1], B[1])) return A[1] > B[1] ? A : B;
|
||||
}
|
||||
|
||||
return C;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the nearest point on a line with a known unit vector that passes through point A
|
||||
* @param A Any point on the line
|
||||
* @param u The unit vector for the line.
|
||||
* @param P A point not on the line to test.
|
||||
* @returns
|
||||
*/
|
||||
static nearestPointOnLineThroughPoint = (A: IVec, u: IVec, P: IVec): IVec => {
|
||||
return Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Negate a vector.
|
||||
* @param A
|
||||
*/
|
||||
static neg = (A: number[]): number[] => {
|
||||
return [-A[0], -A[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get normalized / unit vector.
|
||||
* @param A
|
||||
*/
|
||||
static normalize = (A: IVec): IVec => {
|
||||
return Vec.uni(A);
|
||||
};
|
||||
|
||||
/**
|
||||
* Push a point A towards point B by a given distance.
|
||||
* @param A
|
||||
* @param B
|
||||
* @param d
|
||||
* @returns
|
||||
*/
|
||||
static nudge = (A: IVec, B: IVec, d: number): number[] => {
|
||||
if (Vec.isEqual(A, B)) return A;
|
||||
return Vec.add(A, Vec.mul(Vec.uni(Vec.sub(B, A)), d));
|
||||
};
|
||||
|
||||
/**
|
||||
* Push a point in a given angle by a given distance.
|
||||
* @param A
|
||||
* @param B
|
||||
* @param d
|
||||
*/
|
||||
static nudgeAtAngle = (A: number[], a: number, d: number): number[] => {
|
||||
return [Math.cos(a) * d + A[0], Math.sin(a) * d + A[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Perpendicular rotation of a vector A
|
||||
* @param A
|
||||
*/
|
||||
static per = (A: IVec): IVec => {
|
||||
return [A[1], -A[0]];
|
||||
};
|
||||
|
||||
static pointOffset = (A: IVec, B: IVec, offset: number): IVec => {
|
||||
let u = Vec.uni(Vec.sub(B, A));
|
||||
if (Vec.isEqual(A, B)) u = A;
|
||||
return Vec.add(A, Vec.mul(u, offset));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an array of points between two points.
|
||||
* @param A The first point.
|
||||
* @param B The second point.
|
||||
* @param steps The number of points to return.
|
||||
*/
|
||||
static pointsBetween = (A: IVec, B: IVec, steps = 6): number[][] => {
|
||||
return Array.from({ length: steps }).map((_, i) => {
|
||||
const t = i / (steps - 1);
|
||||
const k = Math.min(1, 0.5 + Math.abs(0.5 - t));
|
||||
return [...Vec.lrp(A, B, t), k];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Project A over B
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static pry = (A: number[], B: number[]): number => {
|
||||
return Vec.dpr(A, B) / Vec.len(B);
|
||||
};
|
||||
|
||||
static rescale = (a: number[], n: number): number[] => {
|
||||
const l = Vec.len(a);
|
||||
return [(n * a[0]) / l, (n * a[1]) / l];
|
||||
};
|
||||
|
||||
/**
|
||||
* Vector rotation by r (radians)
|
||||
* @param A
|
||||
* @param r rotation in radians
|
||||
*/
|
||||
static rot = (A: number[], r = 0): IVec => {
|
||||
return [
|
||||
A[0] * Math.cos(r) - A[1] * Math.sin(r),
|
||||
A[0] * Math.sin(r) + A[1] * Math.cos(r),
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Rotate a vector around another vector by r (radians)
|
||||
* @param A vector
|
||||
* @param C center
|
||||
* @param r rotation in radians
|
||||
*/
|
||||
static rotWith = (A: IVec, C: IVec, r = 0): IVec => {
|
||||
if (r === 0) return A;
|
||||
|
||||
const s = Math.sin(r);
|
||||
const c = Math.cos(r);
|
||||
|
||||
const px = A[0] - C[0];
|
||||
const py = A[1] - C[1];
|
||||
|
||||
const nx = px * c - py * s;
|
||||
const ny = px * s + py * c;
|
||||
|
||||
return [nx + C[0], ny + C[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the slope between two points.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static slope = (A: number[], B: number[]) => {
|
||||
if (A[0] === B[0]) return NaN;
|
||||
return (A[1] - B[1]) / (A[0] - B[0]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Subtract vectors.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static sub = (A: IVec, B: IVec): IVec => {
|
||||
return [A[0] - B[0], A[1] - B[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Subtract scalar from vector.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static subScalar = (A: IVec, n: number): IVec => {
|
||||
return [A[0] - n, A[1] - n];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the tangent between two vectors.
|
||||
* @param A
|
||||
* @param B
|
||||
* @returns
|
||||
*/
|
||||
static tangent = (A: IVec, B: IVec): IVec => {
|
||||
return Vec.uni(Vec.sub(A, B));
|
||||
};
|
||||
|
||||
/**
|
||||
* Round a vector to two decimal places.
|
||||
* @param a
|
||||
*/
|
||||
static toFixed = (a: number[]): number[] => {
|
||||
return a.map(v => Math.round(v * 100) / 100);
|
||||
};
|
||||
|
||||
static toPoint = (v: IVec) => {
|
||||
return {
|
||||
x: v[0],
|
||||
y: v[1],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Round a vector to a precision length.
|
||||
* @param a
|
||||
* @param n
|
||||
*/
|
||||
static toPrecision = (a: number[], n = 4): number[] => {
|
||||
return [+a[0].toPrecision(n), +a[1].toPrecision(n)];
|
||||
};
|
||||
|
||||
static toVec = (v: { x: number; y: number }): IVec => [v.x, v.y];
|
||||
|
||||
/**
|
||||
* Get normalized / unit vector.
|
||||
* @param A
|
||||
*/
|
||||
static uni = (A: IVec): IVec => {
|
||||
return Vec.div(A, Vec.len(A));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the vector from vectors A to B.
|
||||
* @param A
|
||||
* @param B
|
||||
*/
|
||||
static vec = (A: IVec, B: IVec): IVec => {
|
||||
// A, B as vectors get the vector from A to B
|
||||
return [B[0] - A[0], B[1] - A[1]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp a value into a range.
|
||||
* @param n
|
||||
* @param min
|
||||
*/
|
||||
static clamp(n: number, min: number): number;
|
||||
|
||||
// oxlint-disable-next-line typescript/unified-signatures
|
||||
static clamp(n: number, min: number, max: number): number;
|
||||
|
||||
static clamp(n: number, min: number, max?: number): number {
|
||||
return Math.max(min, max !== undefined ? Math.min(n, max) : n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a value into a range.
|
||||
* @param n
|
||||
* @param min
|
||||
*/
|
||||
static clampV(A: IVec, min: number, max?: number): IVec;
|
||||
|
||||
static clampV(A: number[], min: number): number[];
|
||||
|
||||
// oxlint-disable-next-line typescript/unified-signatures
|
||||
static clampV(A: number[], min: number, max: number): number[];
|
||||
|
||||
static clampV(A: number[], min: number, max?: number): number[] {
|
||||
return A.map(n =>
|
||||
max !== undefined ? Vec.clamp(n, min, max) : Vec.clamp(n, min)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross (for point in polygon)
|
||||
*
|
||||
*/
|
||||
static cross(x: number[], y: number[], z: number[]): number {
|
||||
return (y[0] - x[0]) * (z[1] - x[1]) - (z[0] - x[0]) * (y[1] - x[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap vector to nearest step.
|
||||
* @param A
|
||||
* @param step
|
||||
* @example
|
||||
* ```ts
|
||||
* Vec.snap([10.5, 28], 10) // [10, 30]
|
||||
* ```
|
||||
*/
|
||||
static snap(a: number[], step = 1) {
|
||||
return [Math.round(a[0] / step) * step, Math.round(a[1] / step) * step];
|
||||
}
|
||||
}
|
||||
21
blocksuite/framework/global/src/gfx/perfect-freehand/LICENSE
Normal file
21
blocksuite/framework/global/src/gfx/perfect-freehand/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Stephen Ruiz Ltd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IVec, IVec3 } from '../model/index.js';
|
||||
import { getStroke } from './get-stroke.js';
|
||||
|
||||
export function getSolidStrokePoints(
|
||||
points: (IVec | IVec3)[],
|
||||
lineWidth: number
|
||||
) {
|
||||
return getStroke(points, {
|
||||
size: lineWidth,
|
||||
thinning: 0.6,
|
||||
streamline: 0.5,
|
||||
smoothing: 0.5,
|
||||
easing: t => Math.sin((t * Math.PI) / 2),
|
||||
simulatePressure: points[0]?.length === 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { IVec } from '../model/index.js';
|
||||
import { getStrokeRadius } from './get-stroke-radius.js';
|
||||
import type { StrokeOptions, StrokePoint } from './types.js';
|
||||
import {
|
||||
add,
|
||||
dist2,
|
||||
dpr,
|
||||
lrp,
|
||||
mul,
|
||||
neg,
|
||||
per,
|
||||
prj,
|
||||
rotAround,
|
||||
sub,
|
||||
uni,
|
||||
} from './vec.js';
|
||||
|
||||
const { min, PI } = Math;
|
||||
|
||||
// This is the rate of change for simulated pressure. It could be an option.
|
||||
const RATE_OF_PRESSURE_CHANGE = 0.275;
|
||||
|
||||
// Browser strokes seem to be off if PI is regular, a tiny offset seems to fix it
|
||||
const FIXED_PI = PI + 0.0001;
|
||||
|
||||
/**
|
||||
* ## getStrokeOutlinePoints
|
||||
* @description Get an array of points (as `[x, y]`) representing the outline of a stroke.
|
||||
* @param points An array of StrokePoints as returned from `getStrokePoints`.
|
||||
* @param options (optional) An object with options.
|
||||
* @param options.size The base size (diameter) of the stroke.
|
||||
* @param options.thinning The effect of pressure on the stroke's size.
|
||||
* @param options.smoothing How much to soften the stroke's edges.
|
||||
* @param options.easing An easing function to apply to each point's pressure.
|
||||
* @param options.simulatePressure Whether to simulate pressure based on velocity.
|
||||
* @param options.start Cap, taper and easing for the start of the line.
|
||||
* @param options.end Cap, taper and easing for the end of the line.
|
||||
* @param options.last Whether to handle the points as a completed stroke.
|
||||
*/
|
||||
export function getStrokeOutlinePoints(
|
||||
points: StrokePoint[],
|
||||
options: Partial<StrokeOptions> = {} as Partial<StrokeOptions>
|
||||
): IVec[] {
|
||||
const {
|
||||
size = 16,
|
||||
smoothing = 0.5,
|
||||
thinning = 0.5,
|
||||
simulatePressure = true,
|
||||
easing = t => t,
|
||||
start = {},
|
||||
end = {},
|
||||
last: isComplete = false,
|
||||
} = options;
|
||||
|
||||
const { cap: capStart = true, easing: taperStartEase = t => t * (2 - t) } =
|
||||
start;
|
||||
|
||||
const { cap: capEnd = true, easing: taperEndEase = t => --t * t * t + 1 } =
|
||||
end;
|
||||
|
||||
// We can't do anything with an empty array or a stroke with negative size.
|
||||
if (points.length === 0 || size <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The total length of the line
|
||||
const totalLength = points[points.length - 1].runningLength;
|
||||
|
||||
const taperStart =
|
||||
start.taper === false
|
||||
? 0
|
||||
: start.taper === true
|
||||
? Math.max(size, totalLength)
|
||||
: (start.taper as number);
|
||||
|
||||
const taperEnd =
|
||||
end.taper === false
|
||||
? 0
|
||||
: end.taper === true
|
||||
? Math.max(size, totalLength)
|
||||
: (end.taper as number);
|
||||
|
||||
// The minimum allowed distance between points (squared)
|
||||
const minDistance = Math.pow(size * smoothing, 2);
|
||||
|
||||
// Our collected left and right points
|
||||
const leftPts: IVec[] = [];
|
||||
const rightPts: IVec[] = [];
|
||||
|
||||
// Previous pressure (start with average of first five pressures,
|
||||
// in order to prevent fat starts for every line. Drawn lines
|
||||
// almost always start slow!
|
||||
let prevPressure = points.slice(0, 10).reduce((acc, curr) => {
|
||||
let pressure = curr.pressure;
|
||||
|
||||
if (simulatePressure) {
|
||||
// Speed of change - how fast should the the pressure changing?
|
||||
const sp = min(1, curr.distance / size);
|
||||
// Rate of change - how much of a change is there?
|
||||
const rp = min(1, 1 - sp);
|
||||
// Accelerate the pressure
|
||||
pressure = min(1, acc + (rp - acc) * (sp * RATE_OF_PRESSURE_CHANGE));
|
||||
}
|
||||
|
||||
return (acc + pressure) / 2;
|
||||
}, points[0].pressure);
|
||||
|
||||
// The current radius
|
||||
let radius = getStrokeRadius(
|
||||
size,
|
||||
thinning,
|
||||
points[points.length - 1].pressure,
|
||||
easing
|
||||
);
|
||||
|
||||
// The radius of the first saved point
|
||||
let firstRadius: number | undefined = undefined;
|
||||
|
||||
// Previous vector
|
||||
let prevVector = points[0].vector;
|
||||
|
||||
// Previous left and right points
|
||||
let pl = points[0].point;
|
||||
let pr = pl;
|
||||
|
||||
// Temporary left and right points
|
||||
let tl = pl;
|
||||
let tr = pr;
|
||||
|
||||
// Keep track of whether the previous point is a sharp corner
|
||||
// ... so that we don't detect the same corner twice
|
||||
let isPrevPointSharpCorner = false;
|
||||
|
||||
// let short = true
|
||||
|
||||
/*
|
||||
Find the outline's left and right points
|
||||
|
||||
Iterating through the points and populate the rightPts and leftPts arrays,
|
||||
skipping the first and last pointsm, which will get caps later on.
|
||||
*/
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
let { pressure } = points[i];
|
||||
const { point, vector, distance, runningLength } = points[i];
|
||||
|
||||
// Removes noise from the end of the line
|
||||
if (i < points.length - 1 && totalLength - runningLength < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
Calculate the radius
|
||||
|
||||
If not thinning, the current point's radius will be half the size; or
|
||||
otherwise, the size will be based on the current (real or simulated)
|
||||
pressure.
|
||||
*/
|
||||
|
||||
if (thinning) {
|
||||
if (simulatePressure) {
|
||||
// If we're simulating pressure, then do so based on the distance
|
||||
// between the current point and the previous point, and the size
|
||||
// of the stroke. Otherwise, use the input pressure.
|
||||
const sp = min(1, distance / size);
|
||||
const rp = min(1, 1 - sp);
|
||||
pressure = min(
|
||||
1,
|
||||
prevPressure + (rp - prevPressure) * (sp * RATE_OF_PRESSURE_CHANGE)
|
||||
);
|
||||
}
|
||||
|
||||
radius = getStrokeRadius(size, thinning, pressure, easing);
|
||||
} else {
|
||||
radius = size / 2;
|
||||
}
|
||||
|
||||
if (firstRadius === undefined) {
|
||||
firstRadius = radius;
|
||||
}
|
||||
|
||||
/*
|
||||
Apply tapering
|
||||
|
||||
If the current length is within the taper distance at either the
|
||||
start or the end, calculate the taper strengths. Apply the smaller
|
||||
of the two taper strengths to the radius.
|
||||
*/
|
||||
|
||||
const ts =
|
||||
runningLength < taperStart
|
||||
? taperStartEase(runningLength / taperStart)
|
||||
: 1;
|
||||
|
||||
const te =
|
||||
totalLength - runningLength < taperEnd
|
||||
? taperEndEase((totalLength - runningLength) / taperEnd)
|
||||
: 1;
|
||||
|
||||
radius = Math.max(0.01, radius * Math.min(ts, te));
|
||||
|
||||
/* Add points to left and right */
|
||||
|
||||
/*
|
||||
Handle sharp corners
|
||||
|
||||
Find the difference (dot product) between the current and next vector.
|
||||
If the next vector is at more than a right angle to the current vector,
|
||||
draw a cap at the current point.
|
||||
*/
|
||||
|
||||
const nextVector = (i < points.length - 1 ? points[i + 1] : points[i])
|
||||
.vector;
|
||||
const nextDpr = i < points.length - 1 ? dpr(vector, nextVector) : 1.0;
|
||||
const prevDpr = dpr(vector, prevVector);
|
||||
|
||||
const isPointSharpCorner = prevDpr < 0 && !isPrevPointSharpCorner;
|
||||
const isNextPointSharpCorner = nextDpr !== null && nextDpr < 0;
|
||||
|
||||
if (isPointSharpCorner || isNextPointSharpCorner) {
|
||||
// It's a sharp corner. Draw a rounded cap and move on to the next point
|
||||
// Considering saving these and drawing them later? So that we can avoid
|
||||
// crossing future points.
|
||||
|
||||
const offset = mul(per(prevVector), radius);
|
||||
|
||||
for (let step = 1 / 13, t = 0; t <= 1; t += step) {
|
||||
tl = rotAround(sub(point, offset), point, FIXED_PI * t);
|
||||
leftPts.push(tl);
|
||||
|
||||
tr = rotAround(add(point, offset), point, FIXED_PI * -t);
|
||||
rightPts.push(tr);
|
||||
}
|
||||
|
||||
pl = tl;
|
||||
pr = tr;
|
||||
|
||||
if (isNextPointSharpCorner) {
|
||||
isPrevPointSharpCorner = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
isPrevPointSharpCorner = false;
|
||||
|
||||
// Handle the last point
|
||||
if (i === points.length - 1) {
|
||||
const offset = mul(per(vector), radius);
|
||||
leftPts.push(sub(point, offset));
|
||||
rightPts.push(add(point, offset));
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
Add regular points
|
||||
|
||||
Project points to either side of the current point, using the
|
||||
calculated size as a distance. If a point's distance to the
|
||||
previous point on that side greater than the minimum distance
|
||||
(or if the corner is kinda sharp), add the points to the side's
|
||||
points array.
|
||||
*/
|
||||
|
||||
const offset = mul(per(lrp(nextVector, vector, nextDpr)), radius);
|
||||
|
||||
tl = sub(point, offset);
|
||||
|
||||
if (i <= 1 || dist2(pl, tl) > minDistance) {
|
||||
leftPts.push(tl);
|
||||
pl = tl;
|
||||
}
|
||||
|
||||
tr = add(point, offset);
|
||||
|
||||
if (i <= 1 || dist2(pr, tr) > minDistance) {
|
||||
rightPts.push(tr);
|
||||
pr = tr;
|
||||
}
|
||||
|
||||
// Set variables for next iteration
|
||||
prevPressure = pressure;
|
||||
prevVector = vector;
|
||||
}
|
||||
|
||||
/*
|
||||
Drawing caps
|
||||
|
||||
Now that we have our points on either side of the line, we need to
|
||||
draw caps at the start and end. Tapered lines don't have caps, but
|
||||
may have dots for very short lines.
|
||||
*/
|
||||
|
||||
const firstPoint = points[0].point.slice(0, 2) as IVec;
|
||||
|
||||
const lastPoint =
|
||||
points.length > 1
|
||||
? (points[points.length - 1].point.slice(0, 2) as IVec)
|
||||
: add(points[0].point, [1, 1]);
|
||||
|
||||
const startCap: IVec[] = [];
|
||||
|
||||
const endCap: IVec[] = [];
|
||||
|
||||
/*
|
||||
Draw a dot for very short or completed strokes
|
||||
|
||||
If the line is too short to gather left or right points and if the line is
|
||||
not tapered on either side, draw a dot. If the line is tapered, then only
|
||||
draw a dot if the line is both very short and complete. If we draw a dot,
|
||||
we can just return those points.
|
||||
*/
|
||||
|
||||
if (points.length === 1) {
|
||||
if (!(taperStart || taperEnd) || isComplete) {
|
||||
const start = prj(
|
||||
firstPoint,
|
||||
uni(per(sub(firstPoint, lastPoint))),
|
||||
-(firstRadius || radius)
|
||||
);
|
||||
const dotPts: IVec[] = [];
|
||||
for (let step = 1 / 13, t = step; t <= 1; t += step) {
|
||||
dotPts.push(rotAround(start, firstPoint, FIXED_PI * 2 * t));
|
||||
}
|
||||
return dotPts;
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
Draw a start cap
|
||||
|
||||
Unless the line has a tapered start, or unless the line has a tapered end
|
||||
and the line is very short, draw a start cap around the first point. Use
|
||||
the distance between the second left and right point for the cap's radius.
|
||||
Finally remove the first left and right points. :psyduck:
|
||||
*/
|
||||
|
||||
if (taperStart || (taperEnd && points.length === 1)) {
|
||||
// The start point is tapered, noop
|
||||
} else if (capStart) {
|
||||
// Draw the round cap - add thirteen points rotating the right point around the start point to the left point
|
||||
for (let step = 1 / 13, t = step; t <= 1; t += step) {
|
||||
const pt = rotAround(rightPts[0], firstPoint, FIXED_PI * t);
|
||||
startCap.push(pt);
|
||||
}
|
||||
} else {
|
||||
// Draw the flat cap - add a point to the left and right of the start point
|
||||
const cornersVector = sub(leftPts[0], rightPts[0]);
|
||||
const offsetA = mul(cornersVector, 0.5);
|
||||
const offsetB = mul(cornersVector, 0.51);
|
||||
|
||||
startCap.push(
|
||||
sub(firstPoint, offsetA),
|
||||
sub(firstPoint, offsetB),
|
||||
add(firstPoint, offsetB),
|
||||
add(firstPoint, offsetA)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Draw an end cap
|
||||
|
||||
If the line does not have a tapered end, and unless the line has a tapered
|
||||
start and the line is very short, draw a cap around the last point. Finally,
|
||||
remove the last left and right points. Otherwise, add the last point. Note
|
||||
that This cap is a full-turn-and-a-half: this prevents incorrect caps on
|
||||
sharp end turns.
|
||||
*/
|
||||
|
||||
const direction = per(neg(points[points.length - 1].vector));
|
||||
|
||||
if (taperEnd || (taperStart && points.length === 1)) {
|
||||
// Tapered end - push the last point to the line
|
||||
endCap.push(lastPoint);
|
||||
} else if (capEnd) {
|
||||
// Draw the round end cap
|
||||
const start = prj(lastPoint, direction, radius);
|
||||
for (let step = 1 / 29, t = step; t < 1; t += step) {
|
||||
endCap.push(rotAround(start, lastPoint, FIXED_PI * 3 * t));
|
||||
}
|
||||
} else {
|
||||
// Draw the flat end cap
|
||||
|
||||
endCap.push(
|
||||
add(lastPoint, mul(direction, radius)),
|
||||
add(lastPoint, mul(direction, radius * 0.99)),
|
||||
sub(lastPoint, mul(direction, radius * 0.99)),
|
||||
sub(lastPoint, mul(direction, radius))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Return the points in the correct winding order: begin on the left side, then
|
||||
continue around the end cap, then come back along the right side, and finally
|
||||
complete the start cap.
|
||||
*/
|
||||
|
||||
return leftPts.concat(endCap, rightPts.reverse(), startCap);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { IVec, IVec3 } from '../model/index.js';
|
||||
import type { StrokeOptions, StrokePoint } from './types.js';
|
||||
import { add, dist, isEqual, lrp, sub, uni } from './vec.js';
|
||||
|
||||
/**
|
||||
* ## getStrokePoints
|
||||
* @description Get an array of points as objects with an adjusted point, pressure, vector, distance, and runningLength.
|
||||
* @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases.
|
||||
* @param options (optional) An object with options.
|
||||
* @param options.size The base size (diameter) of the stroke.
|
||||
* @param options.thinning The effect of pressure on the stroke's size.
|
||||
* @param options.smoothing How much to soften the stroke's edges.
|
||||
* @param options.easing An easing function to apply to each point's pressure.
|
||||
* @param options.simulatePressure Whether to simulate pressure based on velocity.
|
||||
* @param options.start Cap, taper and easing for the start of the line.
|
||||
* @param options.end Cap, taper and easing for the end of the line.
|
||||
* @param options.last Whether to handle the points as a completed stroke.
|
||||
*/
|
||||
export function getStrokePoints<
|
||||
T extends IVec | IVec3,
|
||||
K extends { x: number; y: number; pressure?: number },
|
||||
>(points: (T | K)[], options = {} as StrokeOptions): StrokePoint[] {
|
||||
const { streamline = 0.5, size = 16, last: isComplete = false } = options;
|
||||
|
||||
// If we don't have any points, return an empty array.
|
||||
if (points.length === 0) return [];
|
||||
|
||||
// Find the interpolation level between points.
|
||||
const t = 0.15 + (1 - streamline) * 0.85;
|
||||
|
||||
// Whatever the input is, make sure that the points are in number[][].
|
||||
let pts: (IVec3 | IVec)[] = Array.isArray(points[0])
|
||||
? (points as T[])
|
||||
: (points as K[]).map(
|
||||
({ x, y, pressure = 0.5 }) => [x, y, pressure] as IVec3
|
||||
);
|
||||
|
||||
// Add extra points between the two, to help avoid "dash" lines
|
||||
// for strokes with tapered start and ends. Don't mutate the
|
||||
// input array!
|
||||
if (pts.length === 2) {
|
||||
const last = pts[1];
|
||||
pts = pts.slice(0, -1);
|
||||
for (let i = 1; i < 5; i++) {
|
||||
pts.push(lrp(pts[0] as IVec, last as IVec, i / 4));
|
||||
}
|
||||
}
|
||||
|
||||
// If there's only one point, add another point at a 1pt offset.
|
||||
// Don't mutate the input array!
|
||||
if (pts.length === 1) {
|
||||
pts = [
|
||||
...pts,
|
||||
[...add(pts[0] as IVec, [1, 1]), ...pts[0].slice(2)] as IVec,
|
||||
];
|
||||
}
|
||||
|
||||
// The strokePoints array will hold the points for the stroke.
|
||||
// Start it out with the first point, which needs no adjustment.
|
||||
const strokePoints: StrokePoint[] = [
|
||||
{
|
||||
point: [pts[0][0], pts[0][1]],
|
||||
pressure: (pts[0][2] ?? -1) >= 0 ? pts[0][2]! : 0.25,
|
||||
vector: [1, 1],
|
||||
distance: 0,
|
||||
runningLength: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// A flag to see whether we've already reached out minimum length
|
||||
let hasReachedMinimumLength = false;
|
||||
|
||||
// We use the runningLength to keep track of the total distance
|
||||
let runningLength = 0;
|
||||
|
||||
// We're set this to the latest point, so we can use it to calculate
|
||||
// the distance and vector of the next point.
|
||||
let prev = strokePoints[0];
|
||||
|
||||
const max = pts.length - 1;
|
||||
|
||||
// Iterate through all of the points, creating StrokePoints.
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const point =
|
||||
isComplete && i === max
|
||||
? // If we're at the last point, and `options.last` is true,
|
||||
// then add the actual input point.
|
||||
(pts[i].slice(0, 2) as IVec)
|
||||
: // Otherwise, using the t calculated from the streamline
|
||||
// option, interpolate a new point between the previous
|
||||
// point the current point.
|
||||
lrp(prev.point, pts[i] as IVec, t);
|
||||
|
||||
// If the new point is the same as the previous point, skip ahead.
|
||||
if (isEqual(prev.point, point)) continue;
|
||||
|
||||
// How far is the new point from the previous point?
|
||||
const distance = dist(point, prev.point);
|
||||
|
||||
// Add this distance to the total "running length" of the line.
|
||||
runningLength += distance;
|
||||
|
||||
// At the start of the line, we wait until the new point is a
|
||||
// certain distance away from the original point, to avoid noise
|
||||
if (i < max && !hasReachedMinimumLength) {
|
||||
if (runningLength < size) continue;
|
||||
hasReachedMinimumLength = true;
|
||||
// TODO: Backfill the missing points so that tapering works correctly.
|
||||
}
|
||||
// Create a new strokepoint (it will be the new "previous" one).
|
||||
prev = {
|
||||
// The adjusted point
|
||||
point,
|
||||
// The input pressure (or .5 if not specified)
|
||||
pressure: (pts[i][2] ?? -1) >= 0 ? pts[i][2]! : 0.5,
|
||||
// The vector from the current point to the previous point
|
||||
vector: uni(sub(prev.point, point)),
|
||||
// The distance between the current point and the previous point
|
||||
distance,
|
||||
// The total distance so far
|
||||
runningLength,
|
||||
};
|
||||
|
||||
// Push it to the strokePoints array.
|
||||
strokePoints.push(prev);
|
||||
}
|
||||
|
||||
// Set the vector of the first point to be the same as the second point.
|
||||
strokePoints[0].vector = strokePoints[1]?.vector || [0, 0];
|
||||
|
||||
return strokePoints;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Compute a radius based on the pressure.
|
||||
* @param size
|
||||
* @param thinning
|
||||
* @param pressure
|
||||
* @param easing
|
||||
* @internal
|
||||
*/
|
||||
export function getStrokeRadius(
|
||||
size: number,
|
||||
thinning: number,
|
||||
pressure: number,
|
||||
easing: (t: number) => number = t => t
|
||||
) {
|
||||
return size * easing(0.5 - thinning * (0.5 - pressure));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IVec, IVec3 } from '../model/index.js';
|
||||
import { getStrokeOutlinePoints } from './get-stroke-outline-points.js';
|
||||
import { getStrokePoints } from './get-stroke-points.js';
|
||||
import type { StrokeOptions } from './types.js';
|
||||
|
||||
/**
|
||||
* ## getStroke
|
||||
* @description Get an array of points describing a polygon that surrounds the input points.
|
||||
* @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases.
|
||||
* @param options (optional) An object with options.
|
||||
* @param options.size The base size (diameter) of the stroke.
|
||||
* @param options.thinning The effect of pressure on the stroke's size.
|
||||
* @param options.smoothing How much to soften the stroke's edges.
|
||||
* @param options.easing An easing function to apply to each point's pressure.
|
||||
* @param options.simulatePressure Whether to simulate pressure based on velocity.
|
||||
* @param options.start Cap, taper and easing for the start of the line.
|
||||
* @param options.end Cap, taper and easing for the end of the line.
|
||||
* @param options.last Whether to handle the points as a completed stroke.
|
||||
*/
|
||||
|
||||
export function getStroke(
|
||||
points: (IVec | IVec3 | { x: number; y: number; pressure?: number })[],
|
||||
options: StrokeOptions = {} as StrokeOptions
|
||||
) {
|
||||
return getStrokeOutlinePoints(getStrokePoints(points, options), options);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './get-solid-stroke-points.js';
|
||||
export * from './get-stroke.js';
|
||||
export * from './get-stroke-outline-points.js';
|
||||
export * from './get-stroke-points.js';
|
||||
export * from './get-stroke-radius.js';
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { IVec } from '../model/index.js';
|
||||
|
||||
/**
|
||||
* The options object for `getStroke` or `getStrokePoints`.
|
||||
* @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases.
|
||||
* @param options (optional) An object with options.
|
||||
* @param options.size The base size (diameter) of the stroke.
|
||||
* @param options.thinning The effect of pressure on the stroke's size.
|
||||
* @param options.smoothing How much to soften the stroke's edges.
|
||||
* @param options.easing An easing function to apply to each point's pressure.
|
||||
* @param options.simulatePressure Whether to simulate pressure based on velocity.
|
||||
* @param options.start Cap, taper and easing for the start of the line.
|
||||
* @param options.end Cap, taper and easing for the end of the line.
|
||||
* @param options.last Whether to handle the points as a completed stroke.
|
||||
*/
|
||||
export interface StrokeOptions {
|
||||
size?: number;
|
||||
thinning?: number;
|
||||
smoothing?: number;
|
||||
streamline?: number;
|
||||
easing?: (pressure: number) => number;
|
||||
simulatePressure?: boolean;
|
||||
start?: {
|
||||
cap?: boolean;
|
||||
taper?: number | boolean;
|
||||
easing?: (distance: number) => number;
|
||||
};
|
||||
end?: {
|
||||
cap?: boolean;
|
||||
taper?: number | boolean;
|
||||
easing?: (distance: number) => number;
|
||||
};
|
||||
// Whether to handle the points as a completed stroke.
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The points returned by `getStrokePoints`, and the input for `getStrokeOutlinePoints`.
|
||||
*/
|
||||
export interface StrokePoint {
|
||||
point: IVec;
|
||||
pressure: number;
|
||||
distance: number;
|
||||
vector: IVec;
|
||||
runningLength: number;
|
||||
}
|
||||
178
blocksuite/framework/global/src/gfx/perfect-freehand/vec.ts
Normal file
178
blocksuite/framework/global/src/gfx/perfect-freehand/vec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { IVec } from '../model/index.js';
|
||||
|
||||
/**
|
||||
* Negate a vector.
|
||||
* @param A
|
||||
* @internal
|
||||
*/
|
||||
export function neg(A: IVec): IVec {
|
||||
return [-A[0], -A[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add vectors.
|
||||
* @param A
|
||||
* @param B
|
||||
* @internal
|
||||
*/
|
||||
export function add(A: IVec, B: IVec): IVec {
|
||||
return [A[0] + B[0], A[1] + B[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract vectors.
|
||||
* @param A
|
||||
* @param B
|
||||
* @internal
|
||||
*/
|
||||
export function sub(A: IVec, B: IVec): IVec {
|
||||
return [A[0] - B[0], A[1] - B[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector multiplication by scalar
|
||||
* @param A
|
||||
* @param n
|
||||
* @internal
|
||||
*/
|
||||
export function mul(A: IVec, n: number): IVec {
|
||||
return [A[0] * n, A[1] * n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector division by scalar.
|
||||
* @param A
|
||||
* @param n
|
||||
* @internal
|
||||
*/
|
||||
export function div(A: IVec, n: number): IVec {
|
||||
return [A[0] / n, A[1] / n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular rotation of a vector A
|
||||
* @param A
|
||||
* @internal
|
||||
*/
|
||||
export function per(A: IVec): IVec {
|
||||
return [A[1], -A[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dot product
|
||||
* @param A
|
||||
* @param B
|
||||
* @internal
|
||||
*/
|
||||
export function dpr(A: IVec, B: IVec) {
|
||||
return A[0] * B[0] + A[1] * B[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether two vectors are equal.
|
||||
* @param A
|
||||
* @param B
|
||||
* @internal
|
||||
*/
|
||||
export function isEqual(A: IVec, B: IVec) {
|
||||
return A[0] === B[0] && A[1] === B[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the vector
|
||||
* @param A
|
||||
* @internal
|
||||
*/
|
||||
export function len(A: IVec) {
|
||||
return Math.hypot(A[0], A[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the vector squared
|
||||
* @param A
|
||||
* @internal
|
||||
*/
|
||||
export function len2(A: IVec) {
|
||||
return A[0] * A[0] + A[1] * A[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dist length from A to B squared.
|
||||
* @param A
|
||||
* @param B
|
||||
* @internal
|
||||
*/
|
||||
export function dist2(A: IVec, B: IVec) {
|
||||
return len2(sub(A, B));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized / unit vector.
|
||||
* @param A
|
||||
* @internal
|
||||
*/
|
||||
export function uni(A: IVec) {
|
||||
return div(A, len(A));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dist length from A to B
|
||||
* @param A
|
||||
* @param B
|
||||
* @internal
|
||||
*/
|
||||
export function dist(A: IVec, B: IVec) {
|
||||
return Math.hypot(A[1] - B[1], A[0] - B[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean between two vectors or mid vector between two vectors
|
||||
* @param A
|
||||
* @param B
|
||||
* @internal
|
||||
*/
|
||||
export function med(A: IVec, B: IVec) {
|
||||
return mul(add(A, B), 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a vector around another vector by r (radians)
|
||||
* @param A vector
|
||||
* @param C center
|
||||
* @param r rotation in radians
|
||||
* @internal
|
||||
*/
|
||||
export function rotAround(A: IVec, C: IVec, r: number): IVec {
|
||||
const s = Math.sin(r);
|
||||
const c = Math.cos(r);
|
||||
|
||||
const px = A[0] - C[0];
|
||||
const py = A[1] - C[1];
|
||||
|
||||
const nx = px * c - py * s;
|
||||
const ny = px * s + py * c;
|
||||
|
||||
return [nx + C[0], ny + C[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate vector A to B with a scalar t
|
||||
* @param A
|
||||
* @param B
|
||||
* @param t scalar
|
||||
* @internal
|
||||
*/
|
||||
export function lrp(A: IVec, B: IVec, t: number) {
|
||||
return add(A, mul(sub(B, A), t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a point A in the direction B by a scalar c
|
||||
* @param A
|
||||
* @param B
|
||||
* @param c
|
||||
* @internal
|
||||
*/
|
||||
export function prj(A: IVec, B: IVec, c: number) {
|
||||
return add(A, mul(B, c));
|
||||
}
|
||||
136
blocksuite/framework/global/src/gfx/polyline.ts
Normal file
136
blocksuite/framework/global/src/gfx/polyline.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type IVec, Vec } from './model/index.js';
|
||||
|
||||
export class Polyline {
|
||||
static len(points: IVec[]) {
|
||||
const n = points.length;
|
||||
|
||||
if (n < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
let len = 0;
|
||||
let curr: IVec;
|
||||
let prev = points[0];
|
||||
|
||||
while (++i < n) {
|
||||
curr = points[i];
|
||||
len += Vec.dist(prev, curr);
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
static lenAtPoint(points: IVec[], point: IVec) {
|
||||
const n = points.length;
|
||||
let len = n;
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
|
||||
// start
|
||||
if (a[0] === point[0] && a[1] === point[1]) {
|
||||
return len;
|
||||
}
|
||||
|
||||
const aa = Vec.angle(a, point);
|
||||
const ba = Vec.angle(b, point);
|
||||
|
||||
if ((aa + ba) % Math.PI === 0) {
|
||||
len += Vec.dist(a, point);
|
||||
return len;
|
||||
}
|
||||
|
||||
len += Vec.dist(a, b);
|
||||
|
||||
// end
|
||||
if (b[0] === point[0] && b[1] === point[1]) {
|
||||
return len;
|
||||
}
|
||||
}
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
static nearestPoint(points: IVec[], point: IVec): IVec {
|
||||
const n = points.length;
|
||||
const r: IVec = [0, 0];
|
||||
let len = Infinity;
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const p = Vec.nearestPointOnLineSegment(a, b, point, true);
|
||||
const d = Vec.dist(p, point);
|
||||
if (d < len) {
|
||||
len = d;
|
||||
r[0] = p[0];
|
||||
r[1] = p[1];
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
static pointAt(points: IVec[], ratio: number) {
|
||||
const n = points.length;
|
||||
|
||||
if (n === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (n === 1) {
|
||||
return points[0];
|
||||
}
|
||||
|
||||
if (ratio <= 0) {
|
||||
return points[0];
|
||||
}
|
||||
|
||||
if (ratio >= 1) {
|
||||
return points[n - 1];
|
||||
}
|
||||
|
||||
const total = Polyline.len(points);
|
||||
const len = total * ratio;
|
||||
return Polyline.pointAtLen(points, len);
|
||||
}
|
||||
|
||||
static pointAtLen(points: IVec[], len: number): IVec | null {
|
||||
const n = points.length;
|
||||
|
||||
if (n === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (n === 1) {
|
||||
return points[0];
|
||||
}
|
||||
|
||||
let fromStart = true;
|
||||
if (len < 0) {
|
||||
fromStart = false;
|
||||
len = -len;
|
||||
}
|
||||
|
||||
let tmp = 0;
|
||||
for (let j = 0, k = n - 1; j < k; j++) {
|
||||
const i = fromStart ? j : k - 1 - j;
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const d = Vec.dist(a, b);
|
||||
|
||||
if (len <= tmp + d) {
|
||||
const t = ((fromStart ? 1 : -1) * (len - tmp)) / d;
|
||||
return Vec.lrp(a, b, t) as IVec;
|
||||
}
|
||||
|
||||
tmp += d;
|
||||
}
|
||||
|
||||
const lastPoint = fromStart ? points[n - 1] : points[0];
|
||||
return lastPoint;
|
||||
}
|
||||
}
|
||||
160
blocksuite/framework/global/src/gfx/svg-path.ts
Normal file
160
blocksuite/framework/global/src/gfx/svg-path.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
interface PathCommand {
|
||||
command: string;
|
||||
coordinates: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility class for building SVG path strings using command-based API.
|
||||
* Supports moveTo, lineTo, curveTo operations and can build complete path strings.
|
||||
*/
|
||||
export class SVGPathBuilder {
|
||||
private commands: PathCommand[] = [];
|
||||
|
||||
/**
|
||||
* Move to a specific point without drawing
|
||||
*/
|
||||
moveTo(x: number, y: number): this {
|
||||
this.commands.push({
|
||||
command: 'M',
|
||||
coordinates: [x, y],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line to a specific point
|
||||
*/
|
||||
lineTo(x: number, y: number): this {
|
||||
this.commands.push({
|
||||
command: 'L',
|
||||
coordinates: [x, y],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a cubic Bézier curve
|
||||
*/
|
||||
curveTo(
|
||||
cp1x: number,
|
||||
cp1y: number,
|
||||
cp2x: number,
|
||||
cp2y: number,
|
||||
x: number,
|
||||
y: number
|
||||
): this {
|
||||
this.commands.push({
|
||||
command: 'C',
|
||||
coordinates: [cp1x, cp1y, cp2x, cp2y, x, y],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current path
|
||||
*/
|
||||
closePath(): this {
|
||||
this.commands.push({
|
||||
command: 'Z',
|
||||
coordinates: [],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete SVG path string
|
||||
*/
|
||||
build(): string {
|
||||
const pathSegments = this.commands.map(cmd => {
|
||||
const coords = cmd.coordinates.join(' ');
|
||||
return coords ? `${cmd.command} ${coords}` : cmd.command;
|
||||
});
|
||||
|
||||
return pathSegments.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all commands and reset the builder
|
||||
*/
|
||||
clear(): this {
|
||||
this.commands = [];
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SVG polygon points string for common shapes
|
||||
*/
|
||||
export class SVGShapeBuilder {
|
||||
/**
|
||||
* Generate diamond (rhombus) polygon points
|
||||
*/
|
||||
static diamond(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
return [
|
||||
`${width / 2},${halfStroke}`,
|
||||
`${width - halfStroke},${height / 2}`,
|
||||
`${width / 2},${height - halfStroke}`,
|
||||
`${halfStroke},${height / 2}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate triangle polygon points
|
||||
*/
|
||||
static triangle(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
return [
|
||||
`${width / 2},${halfStroke}`,
|
||||
`${width - halfStroke},${height - halfStroke}`,
|
||||
`${halfStroke},${height - halfStroke}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate diamond path using SVGPathBuilder
|
||||
*/
|
||||
static diamondPath(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
|
||||
return pathBuilder
|
||||
.moveTo(width / 2, halfStroke)
|
||||
.lineTo(width - halfStroke, height / 2)
|
||||
.lineTo(width / 2, height - halfStroke)
|
||||
.lineTo(halfStroke, height / 2)
|
||||
.closePath()
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate triangle path using SVGPathBuilder
|
||||
*/
|
||||
static trianglePath(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
|
||||
return pathBuilder
|
||||
.moveTo(width / 2, halfStroke)
|
||||
.lineTo(width - halfStroke, height - halfStroke)
|
||||
.lineTo(halfStroke, height - halfStroke)
|
||||
.closePath()
|
||||
.build();
|
||||
}
|
||||
}
|
||||
29
blocksuite/framework/global/src/gfx/xywh.ts
Normal file
29
blocksuite/framework/global/src/gfx/xywh.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* XYWH represents the x, y, width, and height of an element or block.
|
||||
*/
|
||||
export type XYWH = [number, number, number, number];
|
||||
|
||||
/**
|
||||
* SerializedXYWH is a string that represents the x, y, width, and height of a block.
|
||||
*/
|
||||
export type SerializedXYWH = `[${number},${number},${number},${number}]`;
|
||||
|
||||
export function serializeXYWH(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number
|
||||
): SerializedXYWH {
|
||||
return `[${x},${y},${w},${h}]`;
|
||||
}
|
||||
|
||||
export function deserializeXYWH(xywh: string): XYWH {
|
||||
try {
|
||||
return JSON.parse(xywh) as XYWH;
|
||||
} catch (e) {
|
||||
console.error('Failed to deserialize xywh', xywh);
|
||||
console.error(e);
|
||||
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
}
|
||||
1
blocksuite/framework/global/src/index.ts
Normal file
1
blocksuite/framework/global/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
3
blocksuite/framework/global/src/lit/index.ts
Normal file
3
blocksuite/framework/global/src/lit/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './signal-watcher.js';
|
||||
export * from './watch.js';
|
||||
export * from './with-disposable.js';
|
||||
73
blocksuite/framework/global/src/lit/signal-watcher.ts
Normal file
73
blocksuite/framework/global/src/lit/signal-watcher.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
import { effect } from '@preact/signals-core';
|
||||
import type { ReactiveElement } from 'lit';
|
||||
|
||||
type ReactiveElementConstructor = abstract new (
|
||||
...args: any[]
|
||||
) => ReactiveElement;
|
||||
|
||||
/**
|
||||
* Adds the ability for a LitElement or other ReactiveElement class to
|
||||
* watch for access to Preact signals during the update lifecycle and
|
||||
* trigger a new update when signals values change.
|
||||
*/
|
||||
export function SignalWatcher<T extends ReactiveElementConstructor>(
|
||||
Base: T
|
||||
): T {
|
||||
abstract class SignalWatcher extends Base {
|
||||
private __dispose?: () => void;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// In order to listen for signals again after re-connection, we must
|
||||
// re-render to capture all the current signal accesses.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.__dispose?.();
|
||||
}
|
||||
|
||||
override performUpdate() {
|
||||
// ReactiveElement.performUpdate() also does this check, so we want to
|
||||
// also bail early so we don't erroneously appear to not depend on any
|
||||
// signals.
|
||||
if (this.isUpdatePending === false || this.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
// If we have a previous effect, dispose it
|
||||
this.__dispose?.();
|
||||
|
||||
// Tracks whether the effect callback is triggered by this performUpdate
|
||||
// call directly, or by a signal change.
|
||||
let updateFromLit = true;
|
||||
|
||||
// We create a new effect to capture all signal access within the
|
||||
// performUpdate phase (update, render, updated, etc) of the element.
|
||||
// Q: Do we need to create a new effect each render?
|
||||
// TODO: test various combinations of render triggers:
|
||||
// - from requestUpdate()
|
||||
// - from signals
|
||||
// - from both (do we get one or two re-renders)
|
||||
// and see if we really need a new effect here.
|
||||
this.__dispose = effect(() => {
|
||||
if (updateFromLit) {
|
||||
updateFromLit = false;
|
||||
super.performUpdate();
|
||||
} else {
|
||||
// This branch is an effect run from Preact signals.
|
||||
// This will cause another call into performUpdate, which will
|
||||
// then create a new effect watching that update pass.
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return SignalWatcher;
|
||||
}
|
||||
68
blocksuite/framework/global/src/lit/watch.ts
Normal file
68
blocksuite/framework/global/src/lit/watch.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
import { type Signal } from '@preact/signals-core';
|
||||
import { AsyncDirective } from 'lit/async-directive.js';
|
||||
import { directive } from 'lit/directive.js';
|
||||
|
||||
class WatchDirective extends AsyncDirective {
|
||||
private __signal?: Signal;
|
||||
private __dispose?: () => void;
|
||||
|
||||
override render(signal: Signal<unknown>) {
|
||||
if (signal !== this.__signal) {
|
||||
this.__dispose?.();
|
||||
this.__signal = signal;
|
||||
|
||||
// Whether the subscribe() callback is called because of this render
|
||||
// pass, or because of a separate signal update.
|
||||
let updateFromLit = true;
|
||||
this.__dispose = signal.subscribe(value => {
|
||||
// The subscribe() callback is called synchronously during subscribe.
|
||||
// Ignore the first call since we return the value below in that case.
|
||||
if (updateFromLit === false) {
|
||||
this.setValue(value);
|
||||
}
|
||||
});
|
||||
updateFromLit = false;
|
||||
}
|
||||
|
||||
// We use peek() so that the signal access is not tracked by the effect
|
||||
// created by SignalWatcher.performUpdate(). This means that a signal
|
||||
// update won't trigger a full element update if it's only passed to
|
||||
// watch() and not otherwise accessed by the element.
|
||||
return signal.peek();
|
||||
}
|
||||
|
||||
protected override disconnected(): void {
|
||||
this.__dispose?.();
|
||||
}
|
||||
|
||||
protected override reconnected(): void {
|
||||
// Since we disposed the subscription in disconnected() we need to
|
||||
// resubscribe here. We don't ignore the synchronous callback call because
|
||||
// the signal might have changed while the directive is disconnected.
|
||||
//
|
||||
// There are two possible reasons for a disconnect:
|
||||
// 1. The host element was disconnected.
|
||||
// 2. The directive was not rendered during a render
|
||||
// In the first case the element will not schedule an update on reconnect,
|
||||
// so we need the synchronous call here to set the current value.
|
||||
// In the second case, we're probably reconnecting *because* of a render,
|
||||
// so the synchronous call here will go before a render call, and we'll get
|
||||
// two sets of the value (setValue() here and the return in render()), but
|
||||
// this is ok because the value will be dirty-checked by lit-html.
|
||||
this.__dispose = this.__signal?.subscribe(value => {
|
||||
this.setValue(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a signal and subscribes to it, updating the part when the signal
|
||||
* changes.
|
||||
*/
|
||||
export const watch = directive(WatchDirective);
|
||||
53
blocksuite/framework/global/src/lit/with-disposable.ts
Normal file
53
blocksuite/framework/global/src/lit/with-disposable.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import { DisposableGroup } from '../disposable/index.js';
|
||||
import type { Constructor } from '../utils/types.js';
|
||||
|
||||
// See https://lit.dev/docs/composition/mixins/#mixins-in-typescript
|
||||
// This definition should be exported, see https://github.com/microsoft/TypeScript/issues/30355#issuecomment-839834550
|
||||
export declare class DisposableClass {
|
||||
protected _disposables: DisposableGroup;
|
||||
|
||||
readonly disposables: DisposableGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin that adds a `_disposables: DisposableGroup` property to the class.
|
||||
*
|
||||
* The `_disposables` property is initialized in `connectedCallback` and disposed in `disconnectedCallback`.
|
||||
*
|
||||
* see https://lit.dev/docs/composition/mixins/
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyElement extends WithDisposable(ShadowlessElement) {
|
||||
* onClick() {
|
||||
* this._disposables.add(...);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function WithDisposable<T extends Constructor<LitElement>>(
|
||||
SuperClass: T
|
||||
) {
|
||||
class DerivedClass extends SuperClass {
|
||||
protected _disposables = new DisposableGroup();
|
||||
|
||||
get disposables() {
|
||||
return this._disposables;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._disposables.disposed) {
|
||||
this._disposables = new DisposableGroup();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
return DerivedClass as unknown as T & Constructor<DisposableClass>;
|
||||
}
|
||||
1
blocksuite/framework/global/src/types/index.ts
Normal file
1
blocksuite/framework/global/src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './virtual-keyboard.js';
|
||||
43
blocksuite/framework/global/src/types/virtual-keyboard.ts
Normal file
43
blocksuite/framework/global/src/types/virtual-keyboard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
declare global {
|
||||
interface Navigator {
|
||||
readonly virtualKeyboard?: VirtualKeyboard;
|
||||
}
|
||||
|
||||
interface VirtualKeyboard extends EventTarget {
|
||||
readonly boundingRect: DOMRect;
|
||||
overlaysContent: boolean;
|
||||
hide: () => void;
|
||||
show: () => void;
|
||||
ongeometrychange: ((this: VirtualKeyboard, ev: Event) => any) | null;
|
||||
addEventListener<K extends keyof VirtualKeyboardEventMap>(
|
||||
type: K,
|
||||
listener: (this: VirtualKeyboard, ev: VirtualKeyboardEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
removeEventListener<K extends keyof VirtualKeyboardEventMap>(
|
||||
type: K,
|
||||
listener: (this: VirtualKeyboard, ev: VirtualKeyboardEventMap[K]) => any,
|
||||
options?: boolean | EventListenerOptions
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions
|
||||
): void;
|
||||
}
|
||||
|
||||
interface VirtualKeyboardEventMap {
|
||||
geometrychange: Event;
|
||||
}
|
||||
|
||||
interface ElementContentEditable {
|
||||
virtualKeyboardPolicy: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
blocksuite/framework/global/src/utils/crypto.ts
Normal file
12
blocksuite/framework/global/src/utils/crypto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { toBase64 } from 'lib0/buffer.js';
|
||||
import { digest } from 'lib0/hash/sha256';
|
||||
|
||||
export async function sha(input: ArrayBuffer): Promise<string> {
|
||||
const hash =
|
||||
crypto.subtle === undefined // crypto.subtle is not available without a secure context (HTTPS)
|
||||
? digest(new Uint8Array(input))
|
||||
: await crypto.subtle.digest('SHA-256', input);
|
||||
|
||||
// faster conversion from ArrayBuffer to base64 in browser
|
||||
return toBase64(new Uint8Array(hash)).replace(/\+/g, '-').replace(/\//g, '_');
|
||||
}
|
||||
34
blocksuite/framework/global/src/utils/function.ts
Normal file
34
blocksuite/framework/global/src/utils/function.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let resolved = false;
|
||||
signal?.addEventListener('abort', () => {
|
||||
if (!resolved) {
|
||||
clearTimeout(timeId);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const timeId = setTimeout(() => {
|
||||
resolved = true;
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export function noop(_?: unknown) {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function nextTick() {
|
||||
if ('scheduler' in window && 'yield' in window.scheduler) {
|
||||
return window.scheduler.yield();
|
||||
} else if (typeof requestIdleCallback !== 'undefined') {
|
||||
return new Promise(resolve => requestIdleCallback(resolve));
|
||||
} else {
|
||||
return new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
4
blocksuite/framework/global/src/utils/index.ts
Normal file
4
blocksuite/framework/global/src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './crypto.js';
|
||||
export * from './function.js';
|
||||
export * from './logger.js';
|
||||
export * from './types.js';
|
||||
34
blocksuite/framework/global/src/utils/logger.ts
Normal file
34
blocksuite/framework/global/src/utils/logger.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface Logger {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export class ConsoleLogger implements Logger {
|
||||
debug(message: string, ...args: unknown[]) {
|
||||
console.debug(message, ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]) {
|
||||
console.error(message, ...args);
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]) {
|
||||
console.info(message, ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
console.warn(message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopLogger implements Logger {
|
||||
debug() {}
|
||||
|
||||
error() {}
|
||||
|
||||
info() {}
|
||||
|
||||
warn() {}
|
||||
}
|
||||
14
blocksuite/framework/global/src/utils/types.ts
Normal file
14
blocksuite/framework/global/src/utils/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type Constructor<T = object, Arguments extends any[] = any[]> = new (
|
||||
...args: Arguments
|
||||
) => T;
|
||||
|
||||
// Recursive type to make all properties optional
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object
|
||||
? T[P] extends Array<infer U>
|
||||
? Array<DeepPartial<U>>
|
||||
: DeepPartial<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
|
||||
export function assertType<T>(_: unknown): asserts _ is T {}
|
||||
11
blocksuite/framework/global/tsconfig.json
Normal file
11
blocksuite/framework/global/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
// This package MUST NOT use any other package.
|
||||
"references": []
|
||||
}
|
||||
22
blocksuite/framework/global/vitest.config.ts
Normal file
22
blocksuite/framework/global/vitest.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/global',
|
||||
},
|
||||
/**
|
||||
* Custom handler for console.log in tests.
|
||||
*
|
||||
* Return `false` to ignore the log.
|
||||
*/
|
||||
onConsoleLog(log, type) {
|
||||
console.warn(`Unexpected ${type} log`, log);
|
||||
throw new Error(log);
|
||||
},
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user