chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

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

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

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

View File

@@ -0,0 +1,4 @@
import type { ServiceVariant } from './types.js';
export const DEFAULT_SERVICE_VARIANT: ServiceVariant = 'default';
export const ROOT_SCOPE = [];

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

View 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(' -> ');
}

View 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.');
}
}

View 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';

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

View 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('/');
}

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

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

View 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;

View 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,
}

View 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';

View File

@@ -0,0 +1 @@
export {};

View 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';

View 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 {};

View File

@@ -0,0 +1 @@
export * from './utils/index.js';

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

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

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

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

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

View File

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

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

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

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

View 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';

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

View 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() {}
}

View 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,
};
}

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

View File

@@ -0,0 +1,4 @@
export * from './bound.js';
export * from './point.js';
export * from './point-location.js';
export * from './vec.js';

View File

@@ -0,0 +1,94 @@
import { type IVec, Vec } from './vec.js';
/**
* PointLocation is an implementation of IVec with in/out vectors and tangent.
* This is useful when dealing with path.
*/
export class PointLocation extends Array<number> implements IVec {
_in: IVec = [0, 0];
_out: IVec = [0, 0];
// the tangent belongs to the point on the element outline
_tangent: IVec = [0, 0];
[0]: number;
[1]: number;
get absIn() {
return Vec.add(this, this._in);
}
get absOut() {
return Vec.add(this, this._out);
}
get in() {
return this._in;
}
set in(value: IVec) {
this._in = value;
}
override get length() {
return super.length as 2;
}
get out() {
return this._out;
}
set out(value: IVec) {
this._out = value;
}
get tangent() {
return this._tangent;
}
set tangent(value: IVec) {
this._tangent = value;
}
constructor(
point: IVec = [0, 0],
tangent: IVec = [0, 0],
inVec: IVec = [0, 0],
outVec: IVec = [0, 0]
) {
super(2);
this[0] = point[0];
this[1] = point[1];
this._tangent = tangent;
this._in = inVec;
this._out = outVec;
}
static fromVec(vec: IVec) {
const point = new PointLocation();
point[0] = vec[0];
point[1] = vec[1];
return point;
}
clone() {
return new PointLocation(
this as unknown as IVec,
this._tangent,
this._in,
this._out
);
}
setVec(vec: IVec) {
this[0] = vec[0];
this[1] = vec[1];
return this;
}
toVec(): IVec {
return [this[0], this[1]];
}
}

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

View 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];
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Stephen Ruiz Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View 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
}
}
},
};
}
}

View 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];
};

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

View 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];
}
}