mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
58
blocksuite/framework/global/src/__tests__/slot.unit.spec.ts
Normal file
58
blocksuite/framework/global/src/__tests__/slot.unit.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { Slot } from '../utils.js';
|
||||
|
||||
describe('slot', () => {
|
||||
test('init', () => {
|
||||
const slot = new Slot();
|
||||
expect(slot).is.toBeDefined();
|
||||
});
|
||||
|
||||
test('emit', () => {
|
||||
const slot = new Slot<void>();
|
||||
const callback = vi.fn();
|
||||
slot.on(callback);
|
||||
slot.emit();
|
||||
expect(callback).toBeCalled();
|
||||
});
|
||||
|
||||
test('emit with value', () => {
|
||||
const slot = new Slot<number>();
|
||||
const callback = vi.fn(v => expect(v).toBe(5));
|
||||
slot.on(callback);
|
||||
slot.emit(5);
|
||||
expect(callback).toBeCalled();
|
||||
});
|
||||
|
||||
test('listen once', () => {
|
||||
const slot = new Slot<number>();
|
||||
const callback = vi.fn(v => expect(v).toBe(5));
|
||||
slot.once(callback);
|
||||
slot.emit(5);
|
||||
slot.emit(6);
|
||||
expect(callback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listen once with dispose', () => {
|
||||
const slot = new Slot<void>();
|
||||
const callback = vi.fn(() => {
|
||||
throw new Error('');
|
||||
});
|
||||
const disposable = slot.once(callback);
|
||||
disposable.dispose();
|
||||
slot.emit();
|
||||
expect(callback).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('subscribe', () => {
|
||||
type Data = {
|
||||
name: string;
|
||||
age: number;
|
||||
};
|
||||
const slot = new Slot<Data>();
|
||||
const callback = vi.fn(v => expect(v).toBe('田所'));
|
||||
slot.subscribe(v => v.name, callback);
|
||||
slot.emit({ name: '田所', age: 24 });
|
||||
expect(callback).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
69
blocksuite/framework/global/src/__tests__/utils.unit.spec.ts
Normal file
69
blocksuite/framework/global/src/__tests__/utils.unit.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { isEqual } from '../utils.js';
|
||||
|
||||
describe('isEqual', () => {
|
||||
test('number', () => {
|
||||
expect(isEqual(1, 1)).toBe(true);
|
||||
expect(isEqual(1, 114514)).toBe(false);
|
||||
expect(isEqual(NaN, NaN)).toBe(true);
|
||||
expect(isEqual(0, -0)).toBe(false);
|
||||
});
|
||||
|
||||
test('string', () => {
|
||||
expect(isEqual('', '')).toBe(true);
|
||||
expect(isEqual('', ' ')).toBe(false);
|
||||
});
|
||||
|
||||
test('array', () => {
|
||||
expect(isEqual([], [])).toBe(true);
|
||||
expect(isEqual([1, 1, 4, 5, 1, 4], [])).toBe(false);
|
||||
expect(isEqual([1, 1, 4, 5, 1, 4], [1, 1, 4, 5, 1, 4])).toBe(true);
|
||||
});
|
||||
|
||||
test('object', () => {
|
||||
expect(isEqual({}, {})).toBe(true);
|
||||
expect(
|
||||
isEqual(
|
||||
{
|
||||
f: 1,
|
||||
g: {
|
||||
o: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
f: 1,
|
||||
g: {
|
||||
o: '',
|
||||
},
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isEqual({}, { foo: 1 })).toBe(false);
|
||||
// @ts-expect-error FIXME: ts error
|
||||
expect(isEqual({ foo: 1 }, {})).toBe(false);
|
||||
});
|
||||
|
||||
test('nested', () => {
|
||||
const nested = {
|
||||
string: 'this is a string',
|
||||
integer: 42,
|
||||
array: [19, 19, 810, 'test', NaN],
|
||||
nestedArray: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
float: 114.514,
|
||||
undefined,
|
||||
object: {
|
||||
'first-child': true,
|
||||
'second-child': false,
|
||||
'last-child': null,
|
||||
},
|
||||
bigint: 110101195306153019n,
|
||||
};
|
||||
expect(isEqual(nested, nested)).toBe(true);
|
||||
// @ts-expect-error FIXME: ts error
|
||||
expect(isEqual({ foo: [] }, { foo: '' })).toBe(false);
|
||||
});
|
||||
});
|
||||
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(' -> ');
|
||||
}
|
||||
113
blocksuite/framework/global/src/di/identifier.ts
Normal file
113
blocksuite/framework/global/src/di/identifier.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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> & ((variant: ServiceVariant) => ServiceIdentifier<T>) {
|
||||
return Object.assign(
|
||||
(variant: ServiceVariant) => {
|
||||
return createIdentifier<T>(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);
|
||||
};
|
||||
29
blocksuite/framework/global/src/env/index.ts
vendored
Normal file
29
blocksuite/framework/global/src/env/index.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
const agent = globalThis.navigator?.userAgent ?? '';
|
||||
const platform = globalThis.navigator?.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);
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
}
|
||||
34
blocksuite/framework/global/src/exceptions/index.ts
Normal file
34
blocksuite/framework/global/src/exceptions/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ErrorCode } from './code.js';
|
||||
|
||||
export class BlockSuiteError extends Error {
|
||||
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';
|
||||
1
blocksuite/framework/global/src/index.ts
Normal file
1
blocksuite/framework/global/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
22
blocksuite/framework/global/src/types/index.ts
Normal file
22
blocksuite/framework/global/src/types/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface BlockSuiteFlags {
|
||||
enable_synced_doc_block: boolean;
|
||||
enable_pie_menu: boolean;
|
||||
enable_database_number_formatting: boolean;
|
||||
enable_database_attachment_note: boolean;
|
||||
enable_database_full_width: boolean;
|
||||
enable_block_query: boolean;
|
||||
enable_legacy_validation: boolean;
|
||||
enable_lasso_tool: boolean;
|
||||
enable_edgeless_text: boolean;
|
||||
enable_ai_onboarding: boolean;
|
||||
enable_ai_chat_block: boolean;
|
||||
enable_color_picker: boolean;
|
||||
enable_mind_map_import: boolean;
|
||||
enable_advanced_block_visibility: boolean;
|
||||
enable_shape_shadow_blur: boolean;
|
||||
enable_new_dnd: boolean;
|
||||
enable_mobile_keyboard_toolbar: boolean;
|
||||
enable_mobile_linked_doc_menu: boolean;
|
||||
readonly: Record<string, boolean>;
|
||||
}
|
||||
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 {};
|
||||
1
blocksuite/framework/global/src/utils.ts
Normal file
1
blocksuite/framework/global/src/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './utils/index.js';
|
||||
107
blocksuite/framework/global/src/utils/assert.ts
Normal file
107
blocksuite/framework/global/src/utils/assert.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// https://stackoverflow.com/questions/31538010/test-if-a-variable-is-a-primitive-rather-than-an-object
|
||||
import { ErrorCode } from '../exceptions/code.js';
|
||||
import { BlockSuiteError } from '../exceptions/index.js';
|
||||
|
||||
export function isPrimitive(
|
||||
a: unknown
|
||||
): a is null | undefined | boolean | number | string {
|
||||
return a !== Object(a);
|
||||
}
|
||||
|
||||
export function assertType<T>(_: unknown): asserts _ is T {}
|
||||
|
||||
/**
|
||||
* @deprecated Avoid using this util as escape hatch of error handling.
|
||||
* For non-framework code, please handle error in application level instead.
|
||||
*/
|
||||
export function assertExists<T>(
|
||||
val: T | null | undefined,
|
||||
message: string | Error = 'val does not exist',
|
||||
errorCode = ErrorCode.ValueNotExists
|
||||
): asserts val is T {
|
||||
if (val === null || val === undefined) {
|
||||
if (message instanceof Error) {
|
||||
throw message;
|
||||
}
|
||||
throw new BlockSuiteError(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNotExists<T>(
|
||||
val: T | null | undefined,
|
||||
message = 'val exists',
|
||||
errorCode = ErrorCode.ValueNotExists
|
||||
): asserts val is null | undefined {
|
||||
if (val !== null && val !== undefined) {
|
||||
throw new BlockSuiteError(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
export type Equals<X, Y> =
|
||||
///
|
||||
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
|
||||
type Allowed =
|
||||
| unknown
|
||||
| void
|
||||
| null
|
||||
| undefined
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| unknown[]
|
||||
| object;
|
||||
export function isEqual<T extends Allowed, U extends T>(
|
||||
val: T,
|
||||
expected: U
|
||||
): Equals<T, U> {
|
||||
const a = isPrimitive(val);
|
||||
const b = isPrimitive(expected);
|
||||
if (a && b) {
|
||||
if (!Object.is(val, expected)) {
|
||||
return false as Equals<T, U>;
|
||||
}
|
||||
} else if (a !== b) {
|
||||
return false as Equals<T, U>;
|
||||
} else {
|
||||
if (Array.isArray(val) && Array.isArray(expected)) {
|
||||
if (val.length !== expected.length) {
|
||||
return false as Equals<T, U>;
|
||||
}
|
||||
return val.every((x, i) => isEqual(x, expected[i])) as Equals<T, U>;
|
||||
} else if (typeof val === 'object' && typeof expected === 'object') {
|
||||
const obj1 = Object.entries(val as Record<string, unknown>);
|
||||
const obj2 = Object.entries(expected as Record<string, unknown>);
|
||||
if (obj1.length !== obj2.length) {
|
||||
return false as Equals<T, U>;
|
||||
}
|
||||
return obj1.every((x, i) => isEqual(x, obj2[i])) as Equals<T, U>;
|
||||
}
|
||||
}
|
||||
return true as Equals<T, U>;
|
||||
}
|
||||
export function assertEquals<T extends Allowed, U extends T>(
|
||||
val: T,
|
||||
expected: U,
|
||||
message = 'val is not same as expected',
|
||||
errorCode = ErrorCode.ValueNotEqual
|
||||
): asserts val is U {
|
||||
if (!isEqual(val, expected)) {
|
||||
throw new BlockSuiteError(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
export function assertInstanceOf<T>(
|
||||
val: unknown,
|
||||
expected: Class<T>,
|
||||
message = 'val is not instance of expected',
|
||||
errorCode = ErrorCode.ValueNotInstanceOf
|
||||
): asserts val is T {
|
||||
if (!(val instanceof expected)) {
|
||||
throw new BlockSuiteError(errorCode, message);
|
||||
}
|
||||
}
|
||||
175
blocksuite/framework/global/src/utils/bound.ts
Normal file
175
blocksuite/framework/global/src/utils/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
|
||||
),
|
||||
};
|
||||
}
|
||||
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, '_');
|
||||
}
|
||||
404
blocksuite/framework/global/src/utils/curve.ts
Normal file
404
blocksuite/framework/global/src/utils/curve.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
// control coords are not relative to start or end
|
||||
import { assertExists } from './assert.js';
|
||||
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);
|
||||
assertExists(tmpPoint);
|
||||
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);
|
||||
assertExists(pointOnCurve);
|
||||
return pointOnCurve;
|
||||
}
|
||||
|
||||
export function getBezierParameters(
|
||||
points: PointLocation[]
|
||||
): BezierCurveParameters {
|
||||
// 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;
|
||||
}
|
||||
100
blocksuite/framework/global/src/utils/disposable.ts
Normal file
100
blocksuite/framework/global/src/utils/disposable.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
type DisposeCallback = () => void;
|
||||
|
||||
export interface Disposable {
|
||||
dispose: DisposeCallback;
|
||||
}
|
||||
|
||||
export interface DisposableManager extends Disposable {
|
||||
add(d: Disposable | DisposeCallback): void;
|
||||
}
|
||||
|
||||
export class DisposableGroup implements DisposableManager {
|
||||
private _disposables: Disposable[] = [];
|
||||
|
||||
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: Disposable | DisposeCallback) {
|
||||
if (typeof d === 'function') {
|
||||
if (this._disposed) d();
|
||||
else this._disposables.push({ dispose: d });
|
||||
} else {
|
||||
if (this._disposed) d.dispose();
|
||||
else 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 flattenDisposables(disposables: Disposable[]): Disposable {
|
||||
return {
|
||||
dispose: () => disposeAll(disposables),
|
||||
};
|
||||
}
|
||||
|
||||
function disposeAll(disposables: Disposable[]) {
|
||||
for (const disposable of disposables) {
|
||||
try {
|
||||
disposable.dispose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
interface RoundedRectangle {
|
||||
topLeftCornerRadius: number;
|
||||
topRightCornerRadius: number;
|
||||
bottomRightCornerRadius: number;
|
||||
bottomLeftCornerRadius: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface NormalizedCorner {
|
||||
radius: number;
|
||||
roundingAndSmoothingBudget: number;
|
||||
}
|
||||
|
||||
interface NormalizedCorners {
|
||||
topLeft: NormalizedCorner;
|
||||
topRight: NormalizedCorner;
|
||||
bottomLeft: NormalizedCorner;
|
||||
bottomRight: NormalizedCorner;
|
||||
}
|
||||
|
||||
type Corner = keyof NormalizedCorners;
|
||||
|
||||
type Side = 'top' | 'left' | 'right' | 'bottom';
|
||||
|
||||
interface Adjacent {
|
||||
side: Side;
|
||||
corner: Corner;
|
||||
}
|
||||
|
||||
export function distributeAndNormalize({
|
||||
topLeftCornerRadius,
|
||||
topRightCornerRadius,
|
||||
bottomRightCornerRadius,
|
||||
bottomLeftCornerRadius,
|
||||
width,
|
||||
height,
|
||||
}: RoundedRectangle): NormalizedCorners {
|
||||
const roundingAndSmoothingBudgetMap: Record<Corner, number> = {
|
||||
topLeft: -1,
|
||||
topRight: -1,
|
||||
bottomLeft: -1,
|
||||
bottomRight: -1,
|
||||
};
|
||||
|
||||
const cornerRadiusMap: Record<Corner, number> = {
|
||||
topLeft: topLeftCornerRadius,
|
||||
topRight: topRightCornerRadius,
|
||||
bottomLeft: bottomLeftCornerRadius,
|
||||
bottomRight: bottomRightCornerRadius,
|
||||
};
|
||||
|
||||
Object.entries(cornerRadiusMap)
|
||||
// Let the bigger corners choose first
|
||||
.sort(([, radius1], [, radius2]) => {
|
||||
return radius2 - radius1;
|
||||
})
|
||||
.forEach(([cornerName, radius]) => {
|
||||
const corner = cornerName as Corner;
|
||||
const adjacents = adjacentsByCorner[corner];
|
||||
|
||||
// Look at the 2 adjacent sides, figure out how much space we can have on both sides,
|
||||
// then take the smaller one
|
||||
const budget = Math.min(
|
||||
...adjacents.map(adjacent => {
|
||||
const adjacentCornerRadius = cornerRadiusMap[adjacent.corner];
|
||||
if (radius === 0 && adjacentCornerRadius === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const adjacentCornerBudget =
|
||||
roundingAndSmoothingBudgetMap[adjacent.corner];
|
||||
|
||||
const sideLength =
|
||||
adjacent.side === 'top' || adjacent.side === 'bottom'
|
||||
? width
|
||||
: height;
|
||||
|
||||
// If the adjacent corner's already been given the rounding and smoothing budget,
|
||||
// we'll just take the rest
|
||||
if (adjacentCornerBudget >= 0) {
|
||||
return sideLength - roundingAndSmoothingBudgetMap[adjacent.corner];
|
||||
} else {
|
||||
return (radius / (radius + adjacentCornerRadius)) * sideLength;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
roundingAndSmoothingBudgetMap[corner] = budget;
|
||||
cornerRadiusMap[corner] = Math.min(radius, budget);
|
||||
});
|
||||
|
||||
return {
|
||||
topLeft: {
|
||||
radius: cornerRadiusMap.topLeft,
|
||||
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topLeft,
|
||||
},
|
||||
topRight: {
|
||||
radius: cornerRadiusMap.topRight,
|
||||
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topRight,
|
||||
},
|
||||
bottomLeft: {
|
||||
radius: cornerRadiusMap.bottomLeft,
|
||||
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomLeft,
|
||||
},
|
||||
bottomRight: {
|
||||
radius: cornerRadiusMap.bottomRight,
|
||||
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomRight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const adjacentsByCorner: Record<Corner, Array<Adjacent>> = {
|
||||
topLeft: [
|
||||
{
|
||||
corner: 'topRight',
|
||||
side: 'top',
|
||||
},
|
||||
{
|
||||
corner: 'bottomLeft',
|
||||
side: 'left',
|
||||
},
|
||||
],
|
||||
topRight: [
|
||||
{
|
||||
corner: 'topLeft',
|
||||
side: 'top',
|
||||
},
|
||||
{
|
||||
corner: 'bottomRight',
|
||||
side: 'right',
|
||||
},
|
||||
],
|
||||
bottomLeft: [
|
||||
{
|
||||
corner: 'bottomRight',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
corner: 'topLeft',
|
||||
side: 'left',
|
||||
},
|
||||
],
|
||||
bottomRight: [
|
||||
{
|
||||
corner: 'bottomLeft',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
corner: 'topRight',
|
||||
side: 'right',
|
||||
},
|
||||
],
|
||||
};
|
||||
232
blocksuite/framework/global/src/utils/figma-squircle/draw.ts
Normal file
232
blocksuite/framework/global/src/utils/figma-squircle/draw.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
interface CornerPathParams {
|
||||
a: number;
|
||||
b: number;
|
||||
c: number;
|
||||
d: number;
|
||||
p: number;
|
||||
cornerRadius: number;
|
||||
arcSectionLength: number;
|
||||
}
|
||||
|
||||
interface CornerParams {
|
||||
cornerRadius: number;
|
||||
cornerSmoothing: number;
|
||||
preserveSmoothing: boolean;
|
||||
roundingAndSmoothingBudget: number;
|
||||
}
|
||||
|
||||
// The article from figma's blog
|
||||
// https://www.figma.com/blog/desperately-seeking-squircles/
|
||||
//
|
||||
// The original code by MartinRGB
|
||||
// https://github.com/MartinRGB/Figma_Squircles_Approximation/blob/bf29714aab58c54329f3ca130ffa16d39a2ff08c/js/rounded-corners.js#L64
|
||||
export function getPathParamsForCorner({
|
||||
cornerRadius,
|
||||
cornerSmoothing,
|
||||
preserveSmoothing,
|
||||
roundingAndSmoothingBudget,
|
||||
}: CornerParams): CornerPathParams {
|
||||
// From figure 12.2 in the article
|
||||
// p = (1 + cornerSmoothing) * q
|
||||
// in this case q = R because theta = 90deg
|
||||
let p = (1 + cornerSmoothing) * cornerRadius;
|
||||
|
||||
// When there's not enough space left (p > roundingAndSmoothingBudget), there are 2 options:
|
||||
//
|
||||
// 1. What figma's currently doing: limit the smoothing value to make sure p <= roundingAndSmoothingBudget
|
||||
// But what this means is that at some point when cornerRadius is large enough,
|
||||
// increasing the smoothing value wouldn't do anything
|
||||
//
|
||||
// 2. Keep the original smoothing value and use it to calculate the bezier curve normally,
|
||||
// then adjust the control points to achieve similar curvature profile
|
||||
//
|
||||
// preserveSmoothing is a new option I added
|
||||
//
|
||||
// If preserveSmoothing is on then we'll just keep using the original smoothing value
|
||||
// and adjust the bezier curve later
|
||||
if (!preserveSmoothing) {
|
||||
const maxCornerSmoothing = roundingAndSmoothingBudget / cornerRadius - 1;
|
||||
cornerSmoothing = Math.min(cornerSmoothing, maxCornerSmoothing);
|
||||
p = Math.min(p, roundingAndSmoothingBudget);
|
||||
}
|
||||
|
||||
// In a normal rounded rectangle (cornerSmoothing = 0), this is 90
|
||||
// The larger the smoothing, the smaller the arc
|
||||
const arcMeasure = 90 * (1 - cornerSmoothing);
|
||||
const arcSectionLength =
|
||||
Math.sin(toRadians(arcMeasure / 2)) * cornerRadius * Math.sqrt(2);
|
||||
|
||||
// In the article this is the distance between 2 control points: P3 and P4
|
||||
const angleAlpha = (90 - arcMeasure) / 2;
|
||||
const p3ToP4Distance = cornerRadius * Math.tan(toRadians(angleAlpha / 2));
|
||||
|
||||
// a, b, c and d are from figure 11.1 in the article
|
||||
const angleBeta = 45 * cornerSmoothing;
|
||||
const c = p3ToP4Distance * Math.cos(toRadians(angleBeta));
|
||||
const d = c * Math.tan(toRadians(angleBeta));
|
||||
|
||||
let b = (p - arcSectionLength - c - d) / 3;
|
||||
let a = 2 * b;
|
||||
|
||||
// Adjust the P1 and P2 control points if there's not enough space left
|
||||
if (preserveSmoothing && p > roundingAndSmoothingBudget) {
|
||||
const p1ToP3MaxDistance =
|
||||
roundingAndSmoothingBudget - d - arcSectionLength - c;
|
||||
|
||||
// Try to maintain some distance between P1 and P2 so the curve wouldn't look weird
|
||||
const minA = p1ToP3MaxDistance / 6;
|
||||
const maxB = p1ToP3MaxDistance - minA;
|
||||
|
||||
b = Math.min(b, maxB);
|
||||
a = p1ToP3MaxDistance - b;
|
||||
p = Math.min(p, roundingAndSmoothingBudget);
|
||||
}
|
||||
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
p,
|
||||
arcSectionLength,
|
||||
cornerRadius,
|
||||
};
|
||||
}
|
||||
|
||||
interface SVGPathInput {
|
||||
width: number;
|
||||
height: number;
|
||||
topRightPathParams: CornerPathParams;
|
||||
bottomRightPathParams: CornerPathParams;
|
||||
bottomLeftPathParams: CornerPathParams;
|
||||
topLeftPathParams: CornerPathParams;
|
||||
}
|
||||
|
||||
export function getSVGPathFromPathParams({
|
||||
width,
|
||||
height,
|
||||
topLeftPathParams,
|
||||
topRightPathParams,
|
||||
bottomLeftPathParams,
|
||||
bottomRightPathParams,
|
||||
}: SVGPathInput) {
|
||||
return `
|
||||
M ${width - topRightPathParams.p} 0
|
||||
${drawTopRightPath(topRightPathParams)}
|
||||
L ${width} ${height - bottomRightPathParams.p}
|
||||
${drawBottomRightPath(bottomRightPathParams)}
|
||||
L ${bottomLeftPathParams.p} ${height}
|
||||
${drawBottomLeftPath(bottomLeftPathParams)}
|
||||
L 0 ${topLeftPathParams.p}
|
||||
${drawTopLeftPath(topLeftPathParams)}
|
||||
Z
|
||||
`
|
||||
.replace(/[\t\s\n]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function drawTopRightPath({
|
||||
cornerRadius,
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
p,
|
||||
arcSectionLength,
|
||||
}: CornerPathParams) {
|
||||
if (cornerRadius) {
|
||||
return rounded`
|
||||
c ${a} 0 ${a + b} 0 ${a + b + c} ${d}
|
||||
a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${arcSectionLength}
|
||||
c ${d} ${c}
|
||||
${d} ${b + c}
|
||||
${d} ${a + b + c}`;
|
||||
} else {
|
||||
return rounded`l ${p} 0`;
|
||||
}
|
||||
}
|
||||
|
||||
function drawBottomRightPath({
|
||||
cornerRadius,
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
p,
|
||||
arcSectionLength,
|
||||
}: CornerPathParams) {
|
||||
if (cornerRadius) {
|
||||
return rounded`
|
||||
c 0 ${a}
|
||||
0 ${a + b}
|
||||
${-d} ${a + b + c}
|
||||
a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} ${arcSectionLength}
|
||||
c ${-c} ${d}
|
||||
${-(b + c)} ${d}
|
||||
${-(a + b + c)} ${d}`;
|
||||
} else {
|
||||
return rounded`l 0 ${p}`;
|
||||
}
|
||||
}
|
||||
|
||||
function drawBottomLeftPath({
|
||||
cornerRadius,
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
p,
|
||||
arcSectionLength,
|
||||
}: CornerPathParams) {
|
||||
if (cornerRadius) {
|
||||
return rounded`
|
||||
c ${-a} 0
|
||||
${-(a + b)} 0
|
||||
${-(a + b + c)} ${-d}
|
||||
a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} -${arcSectionLength}
|
||||
c ${-d} ${-c}
|
||||
${-d} ${-(b + c)}
|
||||
${-d} ${-(a + b + c)}`;
|
||||
} else {
|
||||
return rounded`l ${-p} 0`;
|
||||
}
|
||||
}
|
||||
|
||||
function drawTopLeftPath({
|
||||
cornerRadius,
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
p,
|
||||
arcSectionLength,
|
||||
}: CornerPathParams) {
|
||||
if (cornerRadius) {
|
||||
return rounded`
|
||||
c 0 ${-a}
|
||||
0 ${-(a + b)}
|
||||
${d} ${-(a + b + c)}
|
||||
a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} -${arcSectionLength}
|
||||
c ${c} ${-d}
|
||||
${b + c} ${-d}
|
||||
${a + b + c} ${-d}`;
|
||||
} else {
|
||||
return rounded`l 0 ${-p}`;
|
||||
}
|
||||
}
|
||||
|
||||
function toRadians(degrees: number) {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function rounded(strings: TemplateStringsArray, ...values: number[]): string {
|
||||
return strings.reduce((acc, str, i) => {
|
||||
const value = values[i];
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return acc + str + value.toFixed(4);
|
||||
} else {
|
||||
return acc + str + (value ?? '');
|
||||
}
|
||||
}, '');
|
||||
}
|
||||
105
blocksuite/framework/global/src/utils/figma-squircle/index.ts
Normal file
105
blocksuite/framework/global/src/utils/figma-squircle/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c)
|
||||
* https://github.com/phamfoo/figma-squircle
|
||||
*/
|
||||
|
||||
import { distributeAndNormalize } from './distribute.js';
|
||||
import { getPathParamsForCorner, getSVGPathFromPathParams } from './draw.js';
|
||||
|
||||
export interface FigmaSquircleParams {
|
||||
cornerRadius?: number;
|
||||
topLeftCornerRadius?: number;
|
||||
topRightCornerRadius?: number;
|
||||
bottomRightCornerRadius?: number;
|
||||
bottomLeftCornerRadius?: number;
|
||||
cornerSmoothing: number;
|
||||
width: number;
|
||||
height: number;
|
||||
preserveSmoothing?: boolean;
|
||||
}
|
||||
|
||||
export function getSvgPath({
|
||||
cornerRadius = 0,
|
||||
topLeftCornerRadius,
|
||||
topRightCornerRadius,
|
||||
bottomRightCornerRadius,
|
||||
bottomLeftCornerRadius,
|
||||
cornerSmoothing,
|
||||
width,
|
||||
height,
|
||||
preserveSmoothing = false,
|
||||
}: FigmaSquircleParams) {
|
||||
topLeftCornerRadius = topLeftCornerRadius ?? cornerRadius;
|
||||
topRightCornerRadius = topRightCornerRadius ?? cornerRadius;
|
||||
bottomLeftCornerRadius = bottomLeftCornerRadius ?? cornerRadius;
|
||||
bottomRightCornerRadius = bottomRightCornerRadius ?? cornerRadius;
|
||||
|
||||
if (
|
||||
topLeftCornerRadius === topRightCornerRadius &&
|
||||
topRightCornerRadius === bottomRightCornerRadius &&
|
||||
bottomRightCornerRadius === bottomLeftCornerRadius &&
|
||||
bottomLeftCornerRadius === topLeftCornerRadius
|
||||
) {
|
||||
const roundingAndSmoothingBudget = Math.min(width, height) / 2;
|
||||
const cornerRadius = Math.min(
|
||||
topLeftCornerRadius,
|
||||
roundingAndSmoothingBudget
|
||||
);
|
||||
|
||||
const pathParams = getPathParamsForCorner({
|
||||
cornerRadius,
|
||||
cornerSmoothing,
|
||||
preserveSmoothing,
|
||||
roundingAndSmoothingBudget,
|
||||
});
|
||||
|
||||
return getSVGPathFromPathParams({
|
||||
width,
|
||||
height,
|
||||
topLeftPathParams: pathParams,
|
||||
topRightPathParams: pathParams,
|
||||
bottomLeftPathParams: pathParams,
|
||||
bottomRightPathParams: pathParams,
|
||||
});
|
||||
}
|
||||
|
||||
const { topLeft, topRight, bottomLeft, bottomRight } = distributeAndNormalize(
|
||||
{
|
||||
topLeftCornerRadius,
|
||||
topRightCornerRadius,
|
||||
bottomRightCornerRadius,
|
||||
bottomLeftCornerRadius,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
);
|
||||
|
||||
return getSVGPathFromPathParams({
|
||||
width,
|
||||
height,
|
||||
topLeftPathParams: getPathParamsForCorner({
|
||||
cornerSmoothing,
|
||||
preserveSmoothing,
|
||||
cornerRadius: topLeft.radius,
|
||||
roundingAndSmoothingBudget: topLeft.roundingAndSmoothingBudget,
|
||||
}),
|
||||
topRightPathParams: getPathParamsForCorner({
|
||||
cornerSmoothing,
|
||||
preserveSmoothing,
|
||||
cornerRadius: topRight.radius,
|
||||
roundingAndSmoothingBudget: topRight.roundingAndSmoothingBudget,
|
||||
}),
|
||||
bottomRightPathParams: getPathParamsForCorner({
|
||||
cornerSmoothing,
|
||||
preserveSmoothing,
|
||||
cornerRadius: bottomRight.radius,
|
||||
roundingAndSmoothingBudget: bottomRight.roundingAndSmoothingBudget,
|
||||
}),
|
||||
bottomLeftPathParams: getPathParamsForCorner({
|
||||
cornerSmoothing,
|
||||
preserveSmoothing,
|
||||
cornerRadius: bottomLeft.radius,
|
||||
roundingAndSmoothingBudget: bottomLeft.roundingAndSmoothingBudget,
|
||||
}),
|
||||
});
|
||||
}
|
||||
126
blocksuite/framework/global/src/utils/function.ts
Normal file
126
blocksuite/framework/global/src/utils/function.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```ts
|
||||
* const log = (message: string) => console.log(`[${new Date().toISOString()}] ${message}`);
|
||||
*
|
||||
* const throttledLog = throttle(log, 1000);
|
||||
*
|
||||
* throttledLog("Hello, world!");
|
||||
* throttledLog("Hello, world!");
|
||||
* throttledLog("Hello, world!");
|
||||
* throttledLog("Hello, world!");
|
||||
* throttledLog("Hello, world!");
|
||||
* ```
|
||||
*/
|
||||
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
limit: number,
|
||||
options?: { leading?: boolean; trailing?: boolean }
|
||||
): T;
|
||||
export function throttle<
|
||||
Args extends unknown[],
|
||||
T extends (...args: Args) => void,
|
||||
>(
|
||||
fn: (...args: Args) => void,
|
||||
limit: number,
|
||||
options?: { leading?: boolean; trailing?: boolean }
|
||||
): T;
|
||||
export function throttle<
|
||||
Args extends unknown[],
|
||||
T extends (this: unknown, ...args: Args) => void,
|
||||
>(fn: T, limit: number, { leading = true, trailing = true } = {}): T {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastArgs: Args | null = null;
|
||||
|
||||
const setTimer = () => {
|
||||
if (lastArgs && trailing) {
|
||||
fn(...lastArgs);
|
||||
lastArgs = null;
|
||||
timer = setTimeout(setTimer, limit);
|
||||
} else {
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return function (this: unknown, ...args: Parameters<T>) {
|
||||
if (timer) {
|
||||
// in throttle
|
||||
lastArgs = args;
|
||||
return;
|
||||
}
|
||||
// Execute the function on the leading edge
|
||||
if (leading) {
|
||||
fn.apply(this, args);
|
||||
}
|
||||
timer = setTimeout(setTimer, limit);
|
||||
} as T;
|
||||
}
|
||||
|
||||
export const debounce = <T extends (...args: any[]) => void>(
|
||||
fn: T,
|
||||
limit: number,
|
||||
{ leading = true, trailing = true } = {}
|
||||
): T => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
const setTimer = () => {
|
||||
if (lastArgs && trailing) {
|
||||
fn(...lastArgs);
|
||||
lastArgs = null;
|
||||
timer = setTimeout(setTimer, limit);
|
||||
} else {
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return function (...args: Parameters<T>) {
|
||||
if (timer) {
|
||||
lastArgs = args;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (leading && !timer) {
|
||||
fn(...args);
|
||||
}
|
||||
timer = setTimeout(setTimer, limit);
|
||||
} as T;
|
||||
};
|
||||
|
||||
export async function nextTick() {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
if ('scheduler' in window && 'yield' in window.scheduler) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return window.scheduler.yield();
|
||||
} else if (typeof requestIdleCallback !== 'undefined') {
|
||||
return new Promise(resolve => requestIdleCallback(resolve));
|
||||
} else {
|
||||
return new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
19
blocksuite/framework/global/src/utils/index.ts
Normal file
19
blocksuite/framework/global/src/utils/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export * from './assert.js';
|
||||
export * from './bound.js';
|
||||
export * from './crypto.js';
|
||||
export * from './curve.js';
|
||||
export * from './disposable.js';
|
||||
export { getSvgPath as getFigmaSquircleSvgPath } from './figma-squircle/index.js';
|
||||
export * from './function.js';
|
||||
export * from './iterable.js';
|
||||
export * from './logger.js';
|
||||
export * from './math.js';
|
||||
export * from './model/index.js';
|
||||
export * from './perfect-freehand/index.js';
|
||||
export * from './polyline.js';
|
||||
export * from './signal-watcher.js';
|
||||
export * from './slot.js';
|
||||
export * from './types.js';
|
||||
export * from './with-disposable.js';
|
||||
export type { SerializedXYWH, XYWH } from './xywh.js';
|
||||
export { deserializeXYWH, serializeXYWH } from './xywh.js';
|
||||
218
blocksuite/framework/global/src/utils/iterable.ts
Normal file
218
blocksuite/framework/global/src/utils/iterable.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const items = [
|
||||
* {name: 'a', classroom: 'c1'},
|
||||
* {name: 'b', classroom: 'c2'},
|
||||
* {name: 'a', classroom: 't0'}
|
||||
* ]
|
||||
* const counted = countBy(items1, i => i.name);
|
||||
* // counted: { a: 2, b: 1}
|
||||
* ```
|
||||
*/
|
||||
export function countBy<T>(
|
||||
items: T[],
|
||||
key: (item: T) => string | number | null
|
||||
): Record<string, number> {
|
||||
const count: Record<string, number> = {};
|
||||
items.forEach(item => {
|
||||
const k = key(item);
|
||||
if (k === null) return;
|
||||
if (!count[k]) {
|
||||
count[k] = 0;
|
||||
}
|
||||
count[k] += 1;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```ts
|
||||
* const items = [{n: 1}, {n: 2}]
|
||||
* const max = maxBy(items, i => i.n);
|
||||
* // max: {n: 2}
|
||||
* ```
|
||||
*/
|
||||
export function maxBy<T>(items: T[], value: (item: T) => number): T | null {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
let maxItem = items[0];
|
||||
let max = value(maxItem);
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const v = value(item);
|
||||
if (v > max) {
|
||||
max = v;
|
||||
maxItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
return maxItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are at least `n` elements in the array that match the given condition.
|
||||
*
|
||||
* @param arr - The input array of elements.
|
||||
* @param matchFn - A function that takes an element of the array and returns a boolean value
|
||||
* indicating if the element matches the desired condition.
|
||||
* @param n - The minimum number of matching elements required.
|
||||
* @returns A boolean value indicating if there are at least `n` matching elements in the array.
|
||||
*
|
||||
* @example
|
||||
* const arr = [1, 2, 3, 4, 5];
|
||||
* const isEven = (num: number): boolean => num % 2 === 0;
|
||||
* console.log(atLeastNMatches(arr, isEven, 2)); // Output: true
|
||||
*/
|
||||
export function atLeastNMatches<T>(
|
||||
arr: T[],
|
||||
matchFn: (element: T) => boolean,
|
||||
n: number
|
||||
): boolean {
|
||||
let count = 0;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (matchFn(arr[i])) {
|
||||
count++;
|
||||
|
||||
if (count >= n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups an array of elements based on a provided key function.
|
||||
*
|
||||
* @example
|
||||
* interface Student {
|
||||
* name: string;
|
||||
* age: number;
|
||||
* }
|
||||
* const students: Student[] = [
|
||||
* { name: 'Alice', age: 25 },
|
||||
* { name: 'Bob', age: 23 },
|
||||
* { name: 'Cathy', age: 25 },
|
||||
* ];
|
||||
* const groupedByAge = groupBy(students, (student) => student.age.toString());
|
||||
* console.log(groupedByAge);
|
||||
* // Output: {
|
||||
* '23': [ { name: 'Bob', age: 23 } ],
|
||||
* '25': [ { name: 'Alice', age: 25 }, { name: 'Cathy', age: 25 } ]
|
||||
* }
|
||||
*/
|
||||
export function groupBy<T, K extends string>(
|
||||
arr: T[],
|
||||
key: K | ((item: T) => K)
|
||||
): Record<K, T[]> {
|
||||
const result = {} as Record<string, T[]>;
|
||||
|
||||
for (const item of arr) {
|
||||
const groupKey = (
|
||||
typeof key === 'function' ? key(item) : (item as any)[key]
|
||||
) as string;
|
||||
|
||||
if (!result[groupKey]) {
|
||||
result[groupKey] = [];
|
||||
}
|
||||
|
||||
result[groupKey].push(item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function pickArray<T>(target: Array<T>, keys: number[]): Array<T> {
|
||||
return keys.reduce((pre, key) => {
|
||||
pre.push(target[key]);
|
||||
return pre;
|
||||
}, [] as T[]);
|
||||
}
|
||||
|
||||
export function pick<T, K extends keyof T>(
|
||||
target: T,
|
||||
keys: K[]
|
||||
): Record<K, T[K]> {
|
||||
return keys.reduce(
|
||||
(pre, key) => {
|
||||
pre[key] = target[key];
|
||||
return pre;
|
||||
},
|
||||
{} as Record<K, T[K]>
|
||||
);
|
||||
}
|
||||
|
||||
export function pickValues<T, K extends keyof T>(
|
||||
target: T,
|
||||
keys: K[]
|
||||
): Array<T[K]> {
|
||||
return keys.reduce(
|
||||
(pre, key) => {
|
||||
pre.push(target[key]);
|
||||
return pre;
|
||||
},
|
||||
[] as Array<T[K]>
|
||||
);
|
||||
}
|
||||
|
||||
export function lastN<T>(target: Array<T>, n: number) {
|
||||
return target.slice(target.length - n, target.length);
|
||||
}
|
||||
|
||||
export function isEmpty(obj: unknown) {
|
||||
if (Object.getPrototypeOf(obj) === Object.prototype) {
|
||||
return Object.keys(obj as object).length === 0;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj) || typeof obj === 'string') {
|
||||
return (obj as Array<unknown>).length === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function keys<T>(obj: T): (keyof T)[] {
|
||||
return Object.keys(obj as object) as (keyof T)[];
|
||||
}
|
||||
|
||||
export function values<T>(obj: T): T[keyof T][] {
|
||||
return Object.values(obj as object);
|
||||
}
|
||||
|
||||
type IterableType<T> = T extends Array<infer U> ? U : T;
|
||||
|
||||
export function last<T extends Iterable<unknown>>(
|
||||
iterable: T
|
||||
): IterableType<T> | undefined {
|
||||
if (Array.isArray(iterable)) {
|
||||
return iterable[iterable.length - 1];
|
||||
}
|
||||
|
||||
let last: unknown | undefined;
|
||||
for (const item of iterable) {
|
||||
last = item;
|
||||
}
|
||||
|
||||
return last as IterableType<T>;
|
||||
}
|
||||
|
||||
export function nToLast<T extends Iterable<unknown>>(
|
||||
iterable: T,
|
||||
n: number
|
||||
): IterableType<T> | undefined {
|
||||
if (Array.isArray(iterable)) {
|
||||
return iterable[iterable.length - n];
|
||||
}
|
||||
|
||||
const arr = [...iterable];
|
||||
|
||||
return arr[arr.length - n] as IterableType<T>;
|
||||
}
|
||||
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() {}
|
||||
}
|
||||
538
blocksuite/framework/global/src/utils/math.ts
Normal file
538
blocksuite/framework/global/src/utils/math.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function polygonNearestPoint(points: IVec[], point: IVec) {
|
||||
const len = points.length;
|
||||
let rst: IVec;
|
||||
let dis = Infinity;
|
||||
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,
|
||||
};
|
||||
}
|
||||
366
blocksuite/framework/global/src/utils/model/bound.ts
Normal file
366
blocksuite/framework/global/src/utils/model/bound.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
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() {
|
||||
return [this.x, this.y + this.h];
|
||||
}
|
||||
|
||||
get br() {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
|
||||
toRelative([x, y]: IVec): IVec {
|
||||
return [(x - this.x) / this.w, (y - this.y) / this.h];
|
||||
}
|
||||
|
||||
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/utils/model/index.ts
Normal file
4
blocksuite/framework/global/src/utils/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';
|
||||
@@ -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/utils/model/point.ts
Normal file
268
blocksuite/framework/global/src/utils/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);
|
||||
}
|
||||
}
|
||||
599
blocksuite/framework/global/src/utils/model/vec.ts
Normal file
599
blocksuite/framework/global/src/utils/model/vec.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
// 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;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/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: number[], min: number): number[];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/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];
|
||||
}
|
||||
}
|
||||
@@ -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/utils/perfect-freehand/vec.ts
Normal file
178
blocksuite/framework/global/src/utils/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/utils/polyline.ts
Normal file
136
blocksuite/framework/global/src/utils/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;
|
||||
}
|
||||
}
|
||||
73
blocksuite/framework/global/src/utils/signal-watcher.ts
Normal file
73
blocksuite/framework/global/src/utils/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;
|
||||
}
|
||||
151
blocksuite/framework/global/src/utils/slot.ts
Normal file
151
blocksuite/framework/global/src/utils/slot.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { type Disposable, flattenDisposables } from './disposable.js';
|
||||
|
||||
// Credits to blocky-editor
|
||||
// https://github.com/vincentdchan/blocky-editor
|
||||
export class Slot<T = void> implements Disposable {
|
||||
private _callbacks: ((v: T) => unknown)[] = [];
|
||||
|
||||
private _disposables: Disposable[] = [];
|
||||
|
||||
private _emitting = false;
|
||||
|
||||
subscribe = <U>(
|
||||
selector: (state: T) => U,
|
||||
callback: (value: U) => void,
|
||||
config?: {
|
||||
equalityFn?: (a: U, b: U) => boolean;
|
||||
filter?: (state: T) => boolean;
|
||||
}
|
||||
) => {
|
||||
let prevState: U | undefined;
|
||||
const { filter, equalityFn = Object.is } = config ?? {};
|
||||
return this.on(state => {
|
||||
if (filter && !filter(state)) {
|
||||
return;
|
||||
}
|
||||
const nextState = selector(state);
|
||||
if (prevState === undefined || !equalityFn(prevState, nextState)) {
|
||||
callback(nextState);
|
||||
prevState = nextState;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
dispose() {
|
||||
flattenDisposables(this._disposables).dispose();
|
||||
this._callbacks = [];
|
||||
this._disposables = [];
|
||||
}
|
||||
|
||||
emit(v: T) {
|
||||
const prevEmitting = this._emitting;
|
||||
this._emitting = true;
|
||||
this._callbacks.forEach(f => {
|
||||
try {
|
||||
f(v);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
this._emitting = prevEmitting;
|
||||
}
|
||||
|
||||
filter(testFun: (v: T) => boolean): Slot<T> {
|
||||
const result = new Slot<T>();
|
||||
// if the original slot is disposed, dispose the filtered one
|
||||
this._disposables.push({
|
||||
dispose: () => result.dispose(),
|
||||
});
|
||||
|
||||
this.on((v: T) => {
|
||||
if (testFun(v)) {
|
||||
result.emit(v);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
flatMap<U>(mapper: (v: T) => U[] | U): Slot<U> {
|
||||
const result = new Slot<U>();
|
||||
this._disposables.push({
|
||||
dispose: () => result.dispose(),
|
||||
});
|
||||
|
||||
this.on((v: T) => {
|
||||
const data = mapper(v);
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach(v => result.emit(v));
|
||||
} else {
|
||||
result.emit(data);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
on(callback: (v: T) => unknown): Disposable {
|
||||
if (this._emitting) {
|
||||
const newCallback = [...this._callbacks, callback];
|
||||
this._callbacks = newCallback;
|
||||
} else {
|
||||
this._callbacks.push(callback);
|
||||
}
|
||||
return {
|
||||
dispose: () => {
|
||||
if (this._emitting) {
|
||||
this._callbacks = this._callbacks.filter(v => v !== callback);
|
||||
} else {
|
||||
const index = this._callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this._callbacks.splice(index, 1); // remove one item only
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
once(callback: (v: T) => unknown): Disposable {
|
||||
let dispose: Disposable['dispose'] | undefined = undefined;
|
||||
const handler = (v: T) => {
|
||||
callback(v);
|
||||
if (dispose) {
|
||||
dispose();
|
||||
}
|
||||
};
|
||||
const disposable = this.on(handler);
|
||||
dispose = disposable.dispose;
|
||||
return disposable;
|
||||
}
|
||||
|
||||
pipe(that: Slot<T>): Slot<T> {
|
||||
this._callbacks.push(v => that.emit(v));
|
||||
return this;
|
||||
}
|
||||
|
||||
toDispose(disposables: Disposable[]): Slot<T> {
|
||||
disposables.push(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
unshift(callback: (v: T) => unknown): Disposable {
|
||||
if (this._emitting) {
|
||||
const newCallback = [callback, ...this._callbacks];
|
||||
this._callbacks = newCallback;
|
||||
} else {
|
||||
this._callbacks.unshift(callback);
|
||||
}
|
||||
return {
|
||||
dispose: () => {
|
||||
if (this._emitting) {
|
||||
this._callbacks = this._callbacks.filter(v => v !== callback);
|
||||
} else {
|
||||
const index = this._callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this._callbacks.splice(index, 1); // remove one item only
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
12
blocksuite/framework/global/src/utils/types.ts
Normal file
12
blocksuite/framework/global/src/utils/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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];
|
||||
};
|
||||
53
blocksuite/framework/global/src/utils/with-disposable.ts
Normal file
53
blocksuite/framework/global/src/utils/with-disposable.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import { DisposableGroup } from './disposable.js';
|
||||
import type { Constructor } from './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>;
|
||||
}
|
||||
29
blocksuite/framework/global/src/utils/xywh.ts
Normal file
29
blocksuite/framework/global/src/utils/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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user