1052 lines
43 KiB
TypeScript
1052 lines
43 KiB
TypeScript
import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas";
|
|
import { clamp, lerp } from "#shared/general.util";
|
|
import { dom, icon, svg } from "#shared/dom.util";
|
|
import render from "#shared/markdown.util";
|
|
import { tooltip } from "#shared/floating.util";
|
|
import { History } from "#shared/history.util";
|
|
import { preview } from "#shared/proses";
|
|
import { SpatialGrid } from "#shared/physics.util";
|
|
import type { CanvasPreferences } from "~/types/general";
|
|
|
|
/*
|
|
|
|
stroke-light-red
|
|
stroke-light-orange
|
|
stroke-light-yellow
|
|
stroke-light-green
|
|
stroke-light-cyan
|
|
stroke-light-purple
|
|
dark:stroke-dark-red
|
|
dark:stroke-dark-orange
|
|
dark:stroke-dark-yellow
|
|
dark:stroke-dark-green
|
|
dark:stroke-dark-cyan
|
|
dark:stroke-dark-purple
|
|
fill-light-red
|
|
fill-light-orange
|
|
fill-light-yellow
|
|
fill-light-green
|
|
fill-light-cyan
|
|
fill-light-purple
|
|
dark:fill-dark-red
|
|
dark:fill-dark-orange
|
|
dark:fill-dark-yellow
|
|
dark:fill-dark-green
|
|
dark:fill-dark-cyan
|
|
dark:fill-dark-purple
|
|
bg-light-red
|
|
bg-light-orange
|
|
bg-light-yellow
|
|
bg-light-green
|
|
bg-light-cyan
|
|
bg-light-purple
|
|
dark:bg-dark-red
|
|
dark:bg-dark-orange
|
|
dark:bg-dark-yellow
|
|
dark:bg-dark-green
|
|
dark:bg-dark-cyan
|
|
dark:bg-dark-purple
|
|
border-light-red
|
|
border-light-orange
|
|
border-light-yellow
|
|
border-light-green
|
|
border-light-cyan
|
|
border-light-purple
|
|
dark:border-dark-red
|
|
dark:border-dark-orange
|
|
dark:border-dark-yellow
|
|
dark:border-dark-green
|
|
dark:border-dark-cyan
|
|
dark:border-dark-purple
|
|
outline-light-red
|
|
outline-light-orange
|
|
outline-light-yellow
|
|
outline-light-green
|
|
outline-light-cyan
|
|
outline-light-purple
|
|
dark:outline-dark-red
|
|
dark:outline-dark-orange
|
|
dark:outline-dark-yellow
|
|
dark:outline-dark-green
|
|
dark:outline-dark-cyan
|
|
dark:outline-dark-purple
|
|
|
|
*/
|
|
|
|
export type Direction = 'bottom' | 'top' | 'left' | 'right';
|
|
export type Position = { x: number, y: number };
|
|
export type Box = Position & { width: number, height: number };
|
|
export type Path = {
|
|
path: string;
|
|
from: Position;
|
|
to: Position;
|
|
side: Direction;
|
|
}
|
|
|
|
export const rotation: Record<Direction, string> = {
|
|
top: "180",
|
|
bottom: "0",
|
|
left: "90",
|
|
right: "270"
|
|
};
|
|
export const opposite: Record<Direction, Direction> = {
|
|
top: "bottom",
|
|
bottom: "top",
|
|
left: "right",
|
|
right: "left"
|
|
}
|
|
|
|
export function edgePos(side: Direction, pos: Position, offset: number): Position {
|
|
switch (side) {
|
|
case "left":
|
|
return {
|
|
x: pos.x - offset,
|
|
y: pos.y
|
|
};
|
|
case "right":
|
|
return {
|
|
x: pos.x + offset,
|
|
y: pos.y
|
|
};
|
|
case "top":
|
|
return {
|
|
x: pos.x,
|
|
y: pos.y - offset
|
|
};
|
|
case "bottom":
|
|
return {
|
|
x: pos.x,
|
|
y: pos.y + offset
|
|
}
|
|
}
|
|
}
|
|
export function getNode(nodes: CanvasNode[], id: string): CanvasNode | undefined
|
|
{
|
|
return nodes.find(e => e.id === id);
|
|
}
|
|
export function posFromDir(e: { minX: number, minY: number, maxX: number, maxY: number }, t: Direction): Position {
|
|
switch (t) {
|
|
case "top":
|
|
return { x: (e.minX + e.maxX) / 2, y: e.minY };
|
|
case "right":
|
|
return { x: e.maxX, y: (e.minY + e.maxY) / 2 };
|
|
case "bottom":
|
|
return { x: (e.minX + e.maxX) / 2, y: e.maxY };
|
|
case "left":
|
|
return { x: e.minX, y: (e.minY + e.maxY) / 2 };
|
|
}
|
|
}
|
|
export function getBbox(node: CanvasNode): { minX: number, minY: number, maxX: number, maxY: number } {
|
|
return { minX: node.x, minY: node.y, maxX: node.x + node.width, maxY: node.y + node.height };
|
|
}
|
|
export function getPath(from: CanvasNode, fromSide: Direction, to: CanvasNode, toSide: Direction): Path | undefined {
|
|
if(from === undefined || to === undefined)
|
|
return;
|
|
|
|
const start = posFromDir(getBbox(from), fromSide), end = posFromDir(getBbox(to), toSide);
|
|
return bezier(start, fromSide, end, toSide);
|
|
}
|
|
export function bezier(from: Position, fromSide: Direction, to: Position, toSide: Direction): Path {
|
|
const r = Math.hypot(from.x - to.x, from.y - to.y), o = clamp(r / 2, 70, 150), a = edgePos(fromSide, from, o), s = edgePos(toSide, to, o);
|
|
return {
|
|
path: `M${from.x},${from.y} C${a.x},${a.y} ${s.x},${s.y} ${to.x},${to.y}`,
|
|
from: from,
|
|
to: to,
|
|
side: toSide,
|
|
};
|
|
}
|
|
export function labelCenter(from: CanvasNode, fromSide: Direction, to: CanvasNode, toSide: Direction): string {
|
|
const start = posFromDir(getBbox(from), fromSide), end = posFromDir(getBbox(to), toSide);
|
|
const len = Math.hypot(start.x - end.x, start.y - end.y), offset = clamp(len / 2, 70, 150), b = edgePos(fromSide, start, offset), s = edgePos(toSide, end, offset);
|
|
const center = getCenter(start, end, b, s, 0.5);
|
|
return `translate(${center.x}px, ${center.y}px)`;
|
|
}
|
|
export function getCenter(n: Position, i: Position, r: Position, o: Position, e: number): Position {
|
|
const a = 1 - e, s = a * a * a, l = 3 * e * a * a, c = 3 * e * e * a, u = e * e * e;
|
|
return {
|
|
x: s * n.x + l * r.x + c * o.x + u * i.x,
|
|
y: s * n.y + l * r.y + c * o.y + u * i.y
|
|
};
|
|
}
|
|
|
|
export function gridSnap(value: number, grid: number): number
|
|
{
|
|
return Math.round(value / grid) * grid;
|
|
}
|
|
|
|
const cancelEvent = (e: Event) => e.preventDefault();
|
|
function center(touches: TouchList): Position
|
|
{
|
|
const pos = { x: 0, y: 0 };
|
|
|
|
for(const touch of touches)
|
|
{
|
|
pos.x += touch.clientX;
|
|
pos.y += touch.clientY;
|
|
}
|
|
|
|
pos.x /= touches.length;
|
|
pos.y /= touches.length;
|
|
return pos;
|
|
}
|
|
function distance(touches: TouchList): number
|
|
{
|
|
const [A, B] = touches;
|
|
if(!A || !B) return 0;
|
|
return Math.hypot(B.clientX - A.clientX, B.clientY - A.clientY);
|
|
}
|
|
|
|
export class Node extends EventTarget
|
|
{
|
|
properties: CanvasNode;
|
|
|
|
nodeDom!: HTMLDivElement;
|
|
|
|
constructor(properties: CanvasNode)
|
|
{
|
|
super();
|
|
|
|
this.properties = properties;
|
|
|
|
this.getDOM()
|
|
}
|
|
|
|
protected getDOM()
|
|
{
|
|
const style = this.style;
|
|
|
|
this.nodeDom = dom('div', { class: ['absolute', {'-z-10': this.properties.type === 'group', 'z-10': this.properties.type !== 'group'}], style: { transform: `translate(${this.properties.x}px, ${this.properties.y}px)`, width: `${this.properties.width}px`, height: `${this.properties.height}px`, '--canvas-color': this.properties.color?.hex } }, [
|
|
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4', style.border] }, [
|
|
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg] }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text)]) : undefined])
|
|
])
|
|
]);
|
|
|
|
if(this.properties.type === 'group')
|
|
{
|
|
if(this.properties.label !== undefined)
|
|
{
|
|
this.nodeDom.appendChild(dom('div', { class: ['origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin', style.border], style: 'max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))', text: this.properties.label }));
|
|
}
|
|
}
|
|
}
|
|
|
|
get style()
|
|
{
|
|
return this.properties.color ? this.properties.color?.class ?
|
|
{ bg: `bg-light-${this.properties.color?.class} dark:bg-dark-${this.properties.color?.class}`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}` } :
|
|
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]` } :
|
|
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` }
|
|
}
|
|
}
|
|
export class NodeEditable extends Node
|
|
{
|
|
edges: Set<EdgeEditable> = new Set();
|
|
|
|
private dirty: boolean = false;
|
|
constructor(properties: CanvasNode)
|
|
{
|
|
super(properties);
|
|
}
|
|
protected override getDOM()
|
|
{
|
|
const style = this.style;
|
|
|
|
this.nodeDom = dom('div', { class: ['absolute group', {'-z-10': this.properties.type === 'group', 'z-10': this.properties.type !== 'group'}], style: { transform: `translate(${this.properties.x}px, ${this.properties.y}px)`, width: `${this.properties.width}px`, height: `${this.properties.height}px`, '--canvas-color': this.properties.color?.hex } }, [
|
|
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full group-hover:outline-4', style.border, style.outline] }, [
|
|
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg], listeners: this.properties.type === 'text' ? { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } : undefined }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text, undefined, { tags: { a: preview } })]) : undefined])
|
|
])
|
|
]);
|
|
|
|
if(this.properties.type === 'group')
|
|
{
|
|
if(this.properties.label !== undefined)
|
|
{
|
|
this.nodeDom.appendChild(dom('div', { class: ['origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin', style.border], style: 'max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))', text: this.properties.label, listeners: { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), mouseleave: e => this.dispatchEvent(new CustomEvent('unfocus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } }));
|
|
}
|
|
}
|
|
}
|
|
update()
|
|
{
|
|
if(!this.dirty)
|
|
return;
|
|
|
|
Object.assign(this.nodeDom.style, {
|
|
transform: `translate(${this.properties.x}px, ${this.properties.y}px)`,
|
|
width: `${this.properties.width}px`,
|
|
height: `${this.properties.height}px`,
|
|
});
|
|
this.edges.forEach(e => e.update());
|
|
this.dirty = false;
|
|
}
|
|
|
|
override get style()
|
|
{
|
|
return this.properties.color ? this.properties.color?.class ?
|
|
{ bg: `bg-light-${this.properties.color?.class} dark:bg-dark-${this.properties.color?.class}`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}`, outline: `outline-light-${this.properties.color?.class} dark:outline-dark-${this.properties.color?.class}` } :
|
|
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]`, outline: `outline-[color:var(--canvas-color)]` } :
|
|
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40`, outline: `outline-light-40 dark:outline-dark-40` }
|
|
}
|
|
|
|
get x()
|
|
{
|
|
return this.properties.x;
|
|
}
|
|
set x(value: number)
|
|
{
|
|
this.properties.x = value;
|
|
this.dirty = true;
|
|
}
|
|
get y()
|
|
{
|
|
return this.properties.y;
|
|
}
|
|
set y(value: number)
|
|
{
|
|
this.properties.y = value;
|
|
this.dirty = true;
|
|
}
|
|
get width()
|
|
{
|
|
return this.properties.width;
|
|
}
|
|
set width(value: number)
|
|
{
|
|
this.properties.width = value;
|
|
this.dirty = true;
|
|
}
|
|
get height()
|
|
{
|
|
return this.properties.height;
|
|
}
|
|
set height(value: number)
|
|
{
|
|
this.properties.height = value;
|
|
this.dirty = true;
|
|
}
|
|
}
|
|
|
|
export class Edge extends EventTarget
|
|
{
|
|
properties: CanvasEdge;
|
|
|
|
edgeDom!: HTMLDivElement;
|
|
protected from: Node;
|
|
protected to: Node;
|
|
protected path: Path;
|
|
protected labelPos: string;
|
|
|
|
constructor(properties: CanvasEdge, from: Node, to: Node)
|
|
{
|
|
super();
|
|
this.properties = properties;
|
|
|
|
this.from = from;
|
|
this.to = to;
|
|
this.path = getPath(this.from.properties, properties.fromSide, this.to.properties, properties.toSide)!;
|
|
this.labelPos = labelCenter(this.from.properties, properties.fromSide, this.to.properties, properties.toSide);
|
|
|
|
this.getDOM();
|
|
}
|
|
|
|
protected getDOM()
|
|
{
|
|
const style = this.style;
|
|
this.edgeDom = dom('div', { class: 'absolute overflow-visible' }, [
|
|
this.properties.label ? dom('div', { style: { transform: `${this.labelPos} translate(-50%, -50%)` }, class: 'relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20', text: this.properties.label }) : undefined,
|
|
svg('svg', { class: 'absolute top-0 overflow-visible h-px w-px' }, [
|
|
svg('g', { style: {'--canvas-color': this.properties.color?.hex}, class: 'z-0' }, [
|
|
svg('g', { style: `transform: translate(${this.path!.to.x}px, ${this.path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[this.path!.side]}deg);` }, [
|
|
svg('polygon', { class: style.fill, attributes: { points: '0,0 6.5,10.4 -6.5,10.4' } }),
|
|
]),
|
|
svg('path', { style: `stroke-width: calc(3px * var(--zoom-multiplier)); stroke-linecap: butt;`, class: [style.stroke, 'fill-none stroke-[4px]'], attributes: { d: this.path!.path } }),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
get style()
|
|
{
|
|
return this.properties.color ? this.properties.color?.class ?
|
|
{ fill: `fill-light-${this.properties.color?.class} dark:fill-dark-${this.properties.color?.class}`, stroke: `stroke-light-${this.properties.color?.class} dark:stroke-dark-${this.properties.color?.class}` } :
|
|
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
|
|
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` }
|
|
}
|
|
}
|
|
export class EdgeEditable extends Edge
|
|
{
|
|
private focusing: boolean = false;
|
|
private editing: boolean = false;
|
|
|
|
private pathDom!: SVGPathElement;
|
|
private inputDom!: HTMLDivElement;
|
|
constructor(properties: CanvasEdge, from: NodeEditable, to: NodeEditable)
|
|
{
|
|
super(properties, from, to);
|
|
from.edges.add(this);
|
|
to.edges.add(this);
|
|
}
|
|
protected override getDOM()
|
|
{
|
|
const style = this.style;
|
|
this.pathDom = svg('path', { style: { 'stroke-width': `calc(3px * var(--zoom-multiplier))`, 'stroke-linecap': 'butt' }, class: ['transition-[stroke-width] fill-none stroke-[4px]', style.stroke], attributes: { d: this.path.path }, listeners: { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } });
|
|
this.inputDom = dom('div', { class: ['relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20 -translate-x-1/2 -translate-y-1/2', { 'hidden': this.properties.label === undefined }], style: { transform: this.labelPos }, text: this.properties.label });
|
|
this.edgeDom = dom('div', { class: 'absolute overflow-visible group' }, [
|
|
this.inputDom,
|
|
svg('svg', { class: 'absolute top-0 overflow-visible h-px w-px' }, [
|
|
svg('g', { style: { '--canvas-color': this.properties.color?.hex } }, [
|
|
svg('g', { style: { transform: `translate(${this.path.to.x}px, ${this.path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[this.path.side]}deg)` } }, [
|
|
svg('polygon', { class: style.fill, attributes: { 'points': '0,0 6.5,10.4 -6.5,10.4' } }),
|
|
]),
|
|
this.pathDom,
|
|
svg('path', { style: { 'stroke-width': `calc(22px * var(--zoom-multiplier))` }, class: ['fill-none transition-opacity z-30 opacity-0 hover:opacity-25', style.stroke], attributes: { d: this.path.path } }),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
update()
|
|
{
|
|
this.path = getPath(this.from.properties, this.properties.fromSide, this.to.properties, this.properties.toSide)!;
|
|
this.pathDom.setAttribute('d', this.path.path);
|
|
}
|
|
}
|
|
|
|
export class Canvas
|
|
{
|
|
static minZoom: number = 0.08;
|
|
static maxZoom: number = 3;
|
|
|
|
protected content: Required<CanvasContent>;
|
|
protected _zoom: number = 0.5;
|
|
protected _x: number = 0;
|
|
protected _y: number = 0;
|
|
|
|
protected containZoom: number = this._zoom;
|
|
protected centerX: number = this._x;
|
|
protected centerY: number = this._y;
|
|
|
|
protected visualZoom: number = this._zoom;
|
|
protected visualX: number = this._x;
|
|
protected visualY: number = this._y;
|
|
|
|
protected tweener: Tweener = new Tweener();
|
|
private debouncedTimeout: Timer = setTimeout(() => {}, 0);
|
|
|
|
protected transform!: HTMLDivElement;
|
|
container!: HTMLDivElement;
|
|
|
|
protected firstX = 0;
|
|
protected firstY = 0;
|
|
protected lastX = 0;
|
|
protected lastY = 0;
|
|
protected lastDistance = 0;
|
|
|
|
protected nodes: Node[] = [];
|
|
protected edges: Edge[] = [];
|
|
|
|
constructor(content?: CanvasContent)
|
|
{
|
|
if(!content)
|
|
content = { nodes: [], edges: [], groups: [] };
|
|
|
|
if(!content.nodes)
|
|
content.nodes = [];
|
|
|
|
if(!content.edges)
|
|
content.edges = [];
|
|
|
|
if(!content.groups)
|
|
content.groups = [];
|
|
|
|
this.content = content as Required<CanvasContent>;
|
|
|
|
this.createDOM();
|
|
}
|
|
|
|
protected createDOM()
|
|
{
|
|
this.nodes = this.content.nodes.map(e => new Node(e));
|
|
this.edges = this.content.edges.map(e => new Edge(e, this.nodes.find(f => e.fromNode === f.properties.id)!, this.nodes.find(f => e.toNode === f.properties.id)!));
|
|
//const { loggedIn, user } = useUserSession();
|
|
this.transform = dom('div', { class: 'origin-center h-full' }, [
|
|
dom('div', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none' }, [
|
|
dom('div', {}, this.nodes.map(e => e.nodeDom)), dom('div', {}, this.edges.map(e => e.edgeDom)),
|
|
])
|
|
]);
|
|
|
|
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none' }, [
|
|
dom('div', { class: 'flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4' }, [
|
|
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), 'Zoom avant', 'right'),
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: (e: MouseEvent) => { this.reset(); } } }, [icon('radix-icons:corners')]), 'Tout contenir', 'right'),
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), 'Zoom arrière', 'right'),
|
|
]),
|
|
]), this.transform,
|
|
]);
|
|
|
|
console.log(this.nodes.length, this.edges.length);
|
|
}
|
|
|
|
protected computeLimits()
|
|
{
|
|
const box = this.container.getBoundingClientRect();
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
|
|
this.content.nodes.forEach(e => {
|
|
minX = Math.min(minX, e.x);
|
|
minY = Math.min(minY, e.y);
|
|
maxX = Math.max(maxX, e.x + e.width);
|
|
maxY = Math.max(maxY, e.y + e.height);
|
|
});
|
|
|
|
this.containZoom = clamp(Math.pow(1 / Math.max((maxX - minX) / box.width, (maxY - minY) / box.height), 1.05), Canvas.minZoom, 1);
|
|
this.centerX = -(minX + (maxX - minX) / 2) + box.width / 2;
|
|
this.centerY = -(minY + (maxY - minY) / 2) + box.height / 2;
|
|
}
|
|
|
|
mount()
|
|
{
|
|
const dragMove = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
this.dragMove(e);
|
|
};
|
|
const dragEnd = (e: MouseEvent) => {
|
|
window.removeEventListener('mouseup', dragEnd);
|
|
window.removeEventListener('mousemove', dragMove);
|
|
|
|
this.dragEnd(e);
|
|
};
|
|
this.container.addEventListener('mouseenter', () => {
|
|
window.addEventListener('wheel', cancelEvent, { passive: false });
|
|
document.addEventListener('gesturestart', cancelEvent);
|
|
document.addEventListener('gesturechange', cancelEvent);
|
|
|
|
this.container.addEventListener('mouseleave', () => {
|
|
window.removeEventListener('wheel', cancelEvent);
|
|
document.removeEventListener('gesturestart', cancelEvent);
|
|
document.removeEventListener('gesturechange', cancelEvent);
|
|
});
|
|
});
|
|
this.container.addEventListener('mousedown', (e) => {
|
|
this.lastX = e.clientX;
|
|
this.lastY = e.clientY;
|
|
|
|
const pos = this.getPosFromCursor(e.clientX, e.clientY);
|
|
this.firstX = pos.x;
|
|
this.firstY = pos.y;
|
|
|
|
window.addEventListener('mouseup', dragEnd);
|
|
window.addEventListener('mousemove', dragMove);
|
|
|
|
this.dragStart(e);
|
|
});
|
|
this.container.addEventListener('wheel', (e) => {
|
|
if((this._zoom >= Canvas.maxZoom && e.deltaY < 0) || (this._zoom <= this.containZoom && e.deltaY > 0))
|
|
return;
|
|
|
|
const box = this.container.getBoundingClientRect(), diff = Math.exp(e.deltaY * -0.001);
|
|
const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2);
|
|
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
|
|
|
|
this.zoomTo(this._x - (mousex / (diff * this._zoom) - mousex / this._zoom), this._y - (mousey / (diff * this._zoom) - mousey / this._zoom), clamp(this._zoom * diff, this.containZoom, Canvas.maxZoom));
|
|
}, { passive: true });
|
|
this.container.addEventListener('touchstart', (e) => {
|
|
({ x: this.lastX, y: this.lastY } = center(e.touches));
|
|
|
|
if(e.touches.length > 1)
|
|
{
|
|
this.lastDistance = distance(e.touches);
|
|
}
|
|
|
|
this.container.addEventListener('touchend', touchend);
|
|
this.container.addEventListener('touchcancel', touchcancel);
|
|
this.container.addEventListener('touchmove', touchmove);
|
|
});
|
|
const touchend = (e: TouchEvent) => {
|
|
if(e.touches.length > 1)
|
|
{
|
|
({ x: this.lastX, y: this.lastY } = center(e.touches));
|
|
}
|
|
|
|
this.container.removeEventListener('touchend', touchend);
|
|
this.container.removeEventListener('touchcancel', touchcancel);
|
|
this.container.removeEventListener('touchmove', touchmove);
|
|
};
|
|
const touchcancel = (e: TouchEvent) => {
|
|
if(e.touches.length > 1)
|
|
{
|
|
({ x: this.lastX, y: this.lastY } = center(e.touches));
|
|
}
|
|
|
|
this.container.removeEventListener('touchend', touchend);
|
|
this.container.removeEventListener('touchcancel', touchcancel);
|
|
this.container.removeEventListener('touchmove', touchmove);
|
|
};
|
|
const touchmove = (e: TouchEvent) => {
|
|
e.preventDefault();
|
|
const pos = center(e.touches);
|
|
this._x = this.visualX = this._x - (this.lastX - pos.x) / this._zoom;
|
|
this._y = this.visualY = this._y - (this.lastY - pos.y) / this._zoom;
|
|
this.lastX = pos.x;
|
|
this.lastY = pos.y;
|
|
|
|
if(e.touches.length === 2)
|
|
{
|
|
const dist = distance(e.touches);
|
|
const diff = dist / this.lastDistance;
|
|
|
|
this._zoom = clamp(this._zoom * diff, this.containZoom, Canvas.maxZoom);
|
|
}
|
|
|
|
this.updateTransform(true);
|
|
};
|
|
|
|
this.computeLimits();
|
|
this.reset();
|
|
}
|
|
|
|
protected updateTransform(debounce: boolean)
|
|
{
|
|
this.transform.style.transform = `scale3d(${this.visualZoom}, ${this.visualZoom}, 1) translate3d(${this.visualX}px, ${this.visualY}px, 0)`;
|
|
|
|
if(debounce)
|
|
{
|
|
clearTimeout(this.debouncedTimeout);
|
|
this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 50);
|
|
}
|
|
}
|
|
|
|
private updateScale()
|
|
{
|
|
this.transform.style.setProperty('--tw-scale', this.visualZoom.toString());
|
|
this.container.style.setProperty('--zoom-multiplier', (1 / Math.pow(this.visualZoom, 0.7)).toFixed(3));
|
|
}
|
|
|
|
protected zoomTo(x: number, y: number, zoom: number)
|
|
{
|
|
const oldX = this._x, oldY = this._y, oldZoom = this._zoom;
|
|
this._x = x;
|
|
this._y = y;
|
|
this._zoom = zoom;
|
|
|
|
this.tweener.update((e) => {
|
|
this.visualX = lerp(e, oldX, x);
|
|
this.visualY = lerp(e, oldY, y);
|
|
this.visualZoom = lerp(e, oldZoom, zoom);
|
|
|
|
this.updateTransform(true);
|
|
}, 50);
|
|
}
|
|
|
|
protected reset()
|
|
{
|
|
this.zoomTo(this.centerX, this.centerY, this.containZoom);
|
|
}
|
|
|
|
protected dragStart(e: MouseEvent) {}
|
|
protected dragMove(e: MouseEvent)
|
|
{
|
|
this._x = this.visualX = this._x - (this.lastX - e.clientX) / this._zoom;
|
|
this._y = this.visualY = this._y - (this.lastY - e.clientY) / this._zoom;
|
|
|
|
this.lastX = e.clientX;
|
|
this.lastY = e.clientY;
|
|
|
|
this.updateTransform(false);
|
|
}
|
|
protected dragEnd(e: MouseEvent) {}
|
|
|
|
protected getPosFromCursor(x: number, y: number): Position
|
|
{
|
|
const box = this.container.getBoundingClientRect();
|
|
const centerX = box.x + box.width / 2, centerY = box.y + box.height / 2;
|
|
return {x: (x - centerX) / this._zoom - this._x + box.width / 2, y: (y - centerY) / this._zoom - this._y + box.height / 2 };
|
|
}
|
|
|
|
get zoom()
|
|
{
|
|
return this._zoom;
|
|
}
|
|
get x()
|
|
{
|
|
return this._x;
|
|
}
|
|
get y()
|
|
{
|
|
return this._y;
|
|
}
|
|
|
|
get viewport()
|
|
{
|
|
const box = this.container.getBoundingClientRect();
|
|
const width = box.width / this._zoom, height = box.height / this._zoom;
|
|
const movementX = box.width - width, movementY = box.height - height;
|
|
return { x: -this._x + movementX / 2, y: -this._y + movementY / 2, width, height };
|
|
}
|
|
}
|
|
export class CanvasEditor extends Canvas
|
|
{
|
|
private static SPACING = 32;
|
|
private history: History;
|
|
private focused: NodeEditable | EdgeEditable | undefined;
|
|
private selection: Set<NodeEditable> = new Set();
|
|
|
|
private dragging: boolean = false;
|
|
private dragger: HTMLElement = dom('div', { class: 'border border-accent-blue absolute shadow-accent-blue pointer-events-none', style: { 'box-shadow': '0 0 2px var(--tw-shadow-color)' } });
|
|
|
|
private pattern: SVGElement = svg('svg', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none' }, [
|
|
svg('pattern', { attributes: { id: 'canvasPattern', patternUnits: 'userSpaceOnUse' } }, [ svg('circle', { class: 'fill-light-35 dark:fill-dark-35', attributes: { cx: '0.75', cy: '0.75', r: '0.75' } }) ]),
|
|
svg('rect', { attributes: { x: '0', y: '0', width: '100%', height: '100%', fill: 'url(#canvasPattern)' } })
|
|
]);
|
|
private nodeHelper: HTMLElement = dom('div', { class: 'cursor-move absolute z-40', listeners: { mousedown: e => this.moveSelection(e) }, style: { width: '0px', height: '0px' } }, [
|
|
dom('span', { class: 'cursor-n-resize absolute -top-3 -right-3 -left-3 h-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 1, 0, -1) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 top-1 left-1/2 -translate-x-2', listeners: { mousedown: e => this.dragNewEdge(e, 'top') } }) ]),
|
|
dom('span', { class: 'cursor-s-resize absolute -bottom-3 -right-3 -left-3 h-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 0, 1) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 bottom-1 left-1/2 -translate-x-2', listeners: { mousedown: e => this.dragNewEdge(e, 'bottom') } }) ]),
|
|
dom('span', { class: 'cursor-e-resize absolute -top-3 -bottom-3 -right-3 w-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 1, 0) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 right-1 top-1/2 -translate-y-2', listeners: { mousedown: e => this.dragNewEdge(e, 'right') } }) ]),
|
|
dom('span', { class: 'cursor-w-resize absolute -top-3 -bottom-3 -left-3 w-6 group', listeners: { mousedown: e => this.resizeSelection(e, 1, 0, -1, 0) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 left-1 top-1/2 -translate-y-2', listeners: { mousedown: e => this.dragNewEdge(e, 'left') } }) ]),
|
|
dom('span', { class: 'cursor-nw-resize absolute -top-4 -left-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 1, 1, -1, -1) } }),
|
|
dom('span', { class: 'cursor-ne-resize absolute -top-4 -right-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 0, 1, 1, -1) } }),
|
|
dom('span', { class: 'cursor-se-resize absolute -bottom-4 -right-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 1, 1) } }),
|
|
dom('span', { class: 'cursor-sw-resize absolute -bottom-4 -left-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 1, 0, -1, 1) } }),
|
|
]);
|
|
private edgeHelper: HTMLElement = dom('div', { class: 'absolute', listeners: { } });
|
|
private boxHelper: HTMLElement = dom('div', { class: '-m-2 border border-accent-purple absolute z-10 p-2 box-content', listeners: { mouseenter: () => this.focusSelection() } });
|
|
|
|
protected override nodes: NodeEditable[] = [];
|
|
protected override edges: EdgeEditable[] = [];
|
|
|
|
private preferences: Ref<CanvasPreferences>;
|
|
private grid: SpatialGrid<NodeEditable> = new SpatialGrid<NodeEditable>(128);
|
|
|
|
constructor(content?: CanvasContent)
|
|
{
|
|
super(content);
|
|
|
|
this.createDOM();
|
|
|
|
this.history = new History();
|
|
this.history.register('canvas', {
|
|
move: {
|
|
undo: action => {
|
|
|
|
},
|
|
redo: action => {
|
|
|
|
},
|
|
}
|
|
});
|
|
|
|
this.preferences = useCookie<CanvasPreferences>('canvasPreference', { default: () => ({ gridSnap: true, neighborSnap: true, spacing: 32 }) });
|
|
}
|
|
protected override createDOM()
|
|
{
|
|
if(!this.grid)
|
|
return;
|
|
|
|
this.nodes = this.content.nodes.map(e => {
|
|
const node = new NodeEditable(e);
|
|
//@ts-ignore
|
|
node.properties.type === "text" && node.addEventListener('focus', this.focusNode.bind(this));
|
|
//@ts-ignore
|
|
node.addEventListener('select', this.selectNode.bind(this));
|
|
//@ts-ignore
|
|
node.addEventListener('edit', this.editNode.bind(this));
|
|
node.properties.type === "text" && this.grid.insert(node);
|
|
return node;
|
|
});
|
|
this.edges = this.content.edges.map(e => {
|
|
const edge = new EdgeEditable(e, this.nodes.find(f => e.fromNode === f.properties.id)!, this.nodes.find(f => e.toNode === f.properties.id)!);
|
|
//@ts-ignore
|
|
edge.addEventListener('focus', this.focusEdge.bind(this));
|
|
//@ts-ignore
|
|
edge.addEventListener('select', this.selectEdge.bind(this));
|
|
//@ts-ignore
|
|
edge.addEventListener('edit', this.editEdge.bind(this));
|
|
return edge;
|
|
});
|
|
|
|
this.transform = dom('div', { class: 'origin-center h-full' }, [
|
|
dom('div', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none' }, [
|
|
dom('div', {}, [...this.nodes.map(e => e.nodeDom), this.nodeHelper]), dom('div', {}, [...this.edges.map(e => e.edgeDom)]),
|
|
]), this.edgeHelper,
|
|
]);
|
|
|
|
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none', listeners: { mousedown: () => { this.selection.clear(); this.updateSelection() } } }, [
|
|
dom('div', { class: 'flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4' }, [
|
|
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), 'Zoom avant', 'right'),
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.reset() } }, [icon('radix-icons:corners')]), 'Tout contenir', 'right'),
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), 'Zoom arrière', 'right'),
|
|
]),
|
|
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.undo() } }, [icon('ph:arrow-bend-up-left')]), 'Annuler (Ctrl+Z)', 'right'),
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.redo() } }, [icon('ph:arrow-bend-up-right')]), 'Rétablir (Ctrl+Y)', 'right'),
|
|
]),
|
|
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:gear')]), 'Préférences', 'right'),
|
|
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:question-mark-circled')]), 'Aide', 'right'),
|
|
]),
|
|
]), this.pattern, this.transform
|
|
]);
|
|
}
|
|
|
|
private focusNode(e: CustomEvent<NodeEditable>)
|
|
{
|
|
if(this.dragging)
|
|
return;
|
|
|
|
e.stopImmediatePropagation();
|
|
|
|
this.focused = e.detail;
|
|
Object.assign(this.nodeHelper.style, {
|
|
transform: `translate(${e.detail.x}px, ${e.detail.y}px)`,
|
|
width: `${e.detail.width}px`,
|
|
height: `${e.detail.height}px`,
|
|
});
|
|
}
|
|
private selectNode(e: CustomEvent<NodeEditable>)
|
|
{
|
|
|
|
}
|
|
private editNode(e: CustomEvent<NodeEditable>)
|
|
{
|
|
|
|
}
|
|
private updateSelection()
|
|
{
|
|
if(this.selection.size > 0)
|
|
{
|
|
const selectionBox = getBox(this.selection);
|
|
Object.assign(this.boxHelper.style, {
|
|
left: `${selectionBox.x}px`,
|
|
top: `${selectionBox.y}px`,
|
|
width: `${selectionBox.width}px`,
|
|
height: `${selectionBox.height}px`,
|
|
});
|
|
|
|
if(this.boxHelper.parentElement === null) this.transform.appendChild(this.boxHelper);
|
|
}
|
|
else
|
|
this.boxHelper.remove();
|
|
}
|
|
private focusSelection()
|
|
{
|
|
if(this.dragging)
|
|
return;
|
|
|
|
const box = this.boxHelper.getBoundingClientRect();
|
|
Object.assign(this.nodeHelper.style, {
|
|
top: `${box.top}px`,
|
|
left: `${box.left}px`,
|
|
width: `${box.width}px`,
|
|
height: `${box.height}px`,
|
|
});
|
|
}
|
|
private moveSelection(e: MouseEvent)
|
|
{
|
|
if(!(e.buttons & 1))
|
|
return;
|
|
|
|
e.stopImmediatePropagation();
|
|
|
|
if(this.selection.size === 0 && this.focused !== undefined)
|
|
{
|
|
this.selection.add(this.focused as NodeEditable);
|
|
this.updateSelection();
|
|
}
|
|
|
|
const end = () => {
|
|
if(moveX !== 0 && moveY !== 0)
|
|
this.history.add('canvas', 'move', [...this.selection.values()].map(e => ({ element: e, from: { x: startX, y: startY }, to: { x: startX + moveX, y: startY + moveY } })));
|
|
|
|
window.removeEventListener('mousemove', move);
|
|
window.removeEventListener('mouseup', end);
|
|
};
|
|
const move = (e: MouseEvent) => {
|
|
const movementX = e.movementX / this._zoom, movementY = e.movementY / this._zoom;
|
|
|
|
moveX += movementX;
|
|
moveY += movementY;
|
|
|
|
this.selection.forEach(_e => {
|
|
_e.x += movementX;
|
|
_e.y += movementY;
|
|
|
|
_e.update();
|
|
});
|
|
|
|
let box = this.boxHelper.getBoundingClientRect();
|
|
Object.assign(this.boxHelper.style, {
|
|
left: `${box.x + movementX}px`,
|
|
top: `${box.y + movementY}px`,
|
|
})
|
|
|
|
box = this.nodeHelper.getBoundingClientRect();
|
|
Object.assign(this.nodeHelper.style, {
|
|
left: `${box.x + movementX}px`,
|
|
top: `${box.y + movementY}px`,
|
|
})
|
|
};
|
|
|
|
const startX = e.clientX, startY = e.clientY;
|
|
let moveX = 0, moveY = 0;
|
|
window.addEventListener('mousemove', move);
|
|
window.addEventListener('mouseup', end);
|
|
}
|
|
private resizeSelection(e: MouseEvent, x: number, y: number, w: number, h: number)
|
|
{
|
|
if(!(e.buttons & 1))
|
|
return;
|
|
|
|
e.stopImmediatePropagation();
|
|
|
|
|
|
}
|
|
private focusEdge(e: CustomEvent<EdgeEditable>)
|
|
{
|
|
this.focused = e.detail;
|
|
}
|
|
private selectEdge(e: CustomEvent<EdgeEditable>)
|
|
{
|
|
|
|
}
|
|
private editEdge(e: CustomEvent<EdgeEditable>)
|
|
{
|
|
|
|
}
|
|
private dragNewEdge(e: MouseEvent, direction: Direction)
|
|
{
|
|
e.stopImmediatePropagation();
|
|
}
|
|
|
|
override updateTransform(debounce: boolean = true)
|
|
{
|
|
super.updateTransform(debounce);
|
|
|
|
this.pattern.parentElement?.classList.toggle('hidden', !this.preferences.value.gridSnap);
|
|
if(this.preferences.value.gridSnap)
|
|
{
|
|
this.pattern.setAttribute("x", (this.viewport.width / 2 + this._x % CanvasEditor.SPACING * this._zoom).toFixed(3));
|
|
this.pattern.setAttribute("y", (this.viewport.height / 2 + this._y % CanvasEditor.SPACING * this._zoom).toFixed(3));
|
|
this.pattern.setAttribute("width", (this._zoom * CanvasEditor.SPACING).toFixed(3));
|
|
this.pattern.setAttribute("height", (this._zoom * CanvasEditor.SPACING).toFixed(3));
|
|
|
|
this.pattern.children[0]!.setAttribute('cx', (this._zoom).toFixed(3));
|
|
this.pattern.children[0]!.setAttribute('cy', (this._zoom).toFixed(3));
|
|
this.pattern.children[0]!.setAttribute('r', (this._zoom).toFixed(3));
|
|
}
|
|
}
|
|
override mount()
|
|
{
|
|
super.mount();
|
|
|
|
this.container.addEventListener('contextmenu', cancelEvent);
|
|
}
|
|
protected override dragStart(e: MouseEvent)
|
|
{
|
|
super.dragStart(e);
|
|
|
|
this.dragging = !!(e.buttons & 1);
|
|
|
|
if(this.dragging)
|
|
{
|
|
this.transform.appendChild(this.dragger);
|
|
|
|
const pos = this.getPosFromCursor(e.clientX, e.clientY);
|
|
Object.assign(this.dragger.style, {
|
|
left: `${pos.x}px`,
|
|
top: `${pos.y}px`,
|
|
width: `0px`,
|
|
height: `0px`,
|
|
});
|
|
}
|
|
}
|
|
protected override dragMove(e: MouseEvent)
|
|
{
|
|
if(this.dragging)
|
|
{
|
|
const pos = this.getPosFromCursor(e.clientX, e.clientY);
|
|
const minX = Math.min(this.firstX, pos.x), minY = Math.min(this.firstY, pos.y), maxX = Math.max(this.firstX, pos.x), maxY = Math.max(this.firstY, pos.y);
|
|
|
|
Object.assign(this.dragger.style, {
|
|
left: `${minX}px`,
|
|
top: `${minY}px`,
|
|
width: `${maxX - minX}px`,
|
|
height: `${maxY - minY}px`,
|
|
});
|
|
|
|
this.selection = new Set(this.grid.query(minX, minY, maxX, maxY));
|
|
this.updateSelection();
|
|
}
|
|
else if(!this.dragging)
|
|
{
|
|
super.dragMove(e);
|
|
}
|
|
}
|
|
protected override dragEnd(e: MouseEvent)
|
|
{
|
|
this.dragging = false;
|
|
this.dragger.remove();
|
|
}
|
|
}
|
|
|
|
function getBox<T extends Box>(selection: Set<T>): Box
|
|
{
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
|
|
selection.forEach(e => {
|
|
minX = Math.min(minX, e.x);
|
|
minY = Math.min(minY, e.y);
|
|
maxX = Math.max(maxX, e.x + e.width);
|
|
maxY = Math.max(maxY, e.y + e.height);
|
|
});
|
|
|
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
}
|
|
|
|
class Tweener
|
|
{
|
|
static linear = (progress: number) => progress;
|
|
|
|
private progress: number;
|
|
private duration: number;
|
|
private last: number;
|
|
|
|
private animationFrame: number = 0;
|
|
|
|
private animation: (progress: number) => number;
|
|
private tick?: (progress: number) => void;
|
|
|
|
constructor(animation: (progress: number) => number = Tweener.linear)
|
|
{
|
|
this.progress = 0, this.duration = 0, this.last = 0;
|
|
this.animation = animation;
|
|
}
|
|
private loop(t: DOMHighResTimeStamp)
|
|
{
|
|
const elapsed = t - this.last;
|
|
this.progress = clamp(this.progress + elapsed, 0, this.duration);
|
|
this.last = t;
|
|
|
|
const step = this.animation(clamp(this.progress / this.duration, 0, 1));
|
|
this.tick!(step);
|
|
|
|
if(this.progress < this.duration)
|
|
this.animationFrame = requestAnimationFrame(this.loop.bind(this));
|
|
}
|
|
update(tick: (progress: number) => void, duration: number)
|
|
{
|
|
this.duration = duration + this.duration - this.progress;
|
|
this.progress = 0;
|
|
this.last = performance.now();
|
|
this.tick = tick;
|
|
|
|
cancelAnimationFrame(this.animationFrame);
|
|
this.animationFrame = requestAnimationFrame(this.loop.bind(this));
|
|
}
|
|
stop()
|
|
{
|
|
cancelAnimationFrame(this.animationFrame);
|
|
|
|
this.duration = 0;
|
|
this.progress = 0;
|
|
}
|
|
} |