import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas"; import { clamp, lerp } from "#shared/general.util"; import { dom, icon, svg, text } from "./dom.util"; import render from "./markdown.util"; import { popper } from "#shared/floating.util"; import { Content } from "./content.util"; import type { History } from "./history.util"; import { fakeA } from "./proses"; export type Direction = 'bottom' | 'top' | 'left' | 'right'; export type Position = { x: number, y: number }; export type Box = Position & { w: number, h: number }; export type Path = { path: string; from: Position; to: Position; side: Direction; } export const rotation: Record = { top: "180", bottom: "0", left: "90", right: "270" }; export const opposite: Record = { 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; 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 { private static input: HTMLInputElement = dom('input', { class: 'origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] appearance-none bg-transparent outline-4 mb-2 px-2 py-1 font-thin min-w-4', style: { 'max-width': '100%', 'font-size': 'calc(18px * var(--zoom-multiplier))' }, listeners: { click: e => e.stopImmediatePropagation() } }); constructor(properties: CanvasNode) { super(properties); } protected override getDOM() { const style = this.style; /*
{{ node.label }}
*/ 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, 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 === 'group' ? { 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 })) } : undefined }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text, undefined, { tags: { a: fakeA } })]) : 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 })) } })); } } } 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` } } } export class Edge extends EventTarget { properties: CanvasEdge; edgeDom!: HTMLDivElement; protected from: CanvasNode; protected to: CanvasNode; protected path: Path; protected labelPos: string; constructor(properties: CanvasEdge, nodes: CanvasNode[]) { super(); this.properties = properties; this.from = nodes.find(f => f.id === properties.fromNode)!; this.to = nodes.find(f => f.id === properties.toNode)!; this.path = getPath(this.from, properties.fromSide, this.to, properties.toSide)!; this.labelPos = labelCenter(this.from, properties.fromSide, this.to, 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 static input: HTMLInputElement = dom('input', { 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', listeners: { click: e => e.stopImmediatePropagation() } }); private focusing: boolean = false; private editing: boolean = false; private pathDom!: SVGPathElement; private inputDom!: HTMLDivElement; constructor(properties: CanvasEdge, nodes: CanvasNode[]) { super(properties, nodes); } 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], 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 } }), ]), ]), ]); } } export class Canvas { static maxZoom: number = 3; protected content: Required; 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; 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; this.createDOM(); } protected createDOM() { 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.content.nodes.map(e => new Node(e).nodeDom)]), dom('div', {}, [...this.content.edges.map(e => new Edge(e, this.content.nodes).edgeDom)]), ]) ]); //TODO: --zoom-multiplier dynamic 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' }, [ popper(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')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom avant')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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:reload')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Reset')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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.zoomTo(this.x, this.y, this.containZoom); } } }, [icon('radix-icons:corners')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Tout contenir')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom arrière')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), ]), //dom('a') Edition link ]), this.transform, ]); /* */ } 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 = Math.pow(1 / Math.max((maxX - minX) / box.width, (maxY - minY) / box.height), 1.05); this.centerX = -(minX + (maxX - minX) / 2) + box.width / 2; this.centerY = -(minY + (maxY - minY) / 2) + box.height / 2; } mount() { let lastX = 0, lastY = 0, lastDistance = 0; const dragMove = (e: MouseEvent) => { this.x = this.visualX = this.x - (lastX - e.layerX) / this.zoom; this.y = this.visualY = this.y - (lastY - e.layerY) / this.zoom; lastX = e.layerX; lastY = e.layerY; this.updateTransform(); }; const dragEnd = (e: MouseEvent) => { window.removeEventListener('mouseup', dragEnd); window.removeEventListener('mousemove', dragMove); }; 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) => { lastX = e.layerX; lastY = e.layerY; window.addEventListener('mouseup', dragEnd, { passive: true }); window.addEventListener('mousemove', dragMove, { passive: true }); }, { passive: true }); this.container.addEventListener('wheel', (e) => { if((this.zoom >= Canvas.maxZoom && e.deltaY < 0) || (this.zoom <= this.containZoom && e.deltaY > 0)) return; let box = this.container.getBoundingClientRect()!; const 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: lastX, y: lastY } = center(e.touches)); if(e.touches.length > 1) { lastDistance = distance(e.touches); } this.container.addEventListener('touchend', touchend, { passive: true }); this.container.addEventListener('touchcancel', touchcancel, { passive: true }); this.container.addEventListener('touchmove', touchmove, { passive: true }); }, { passive: true }); const touchend = (e: TouchEvent) => { if(e.touches.length > 1) { ({ x: lastX, y: 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: lastX, y: lastY } = center(e.touches)); } this.container.removeEventListener('touchend', touchend); this.container.removeEventListener('touchcancel', touchcancel); this.container.removeEventListener('touchmove', touchmove); }; const touchmove = (e: TouchEvent) => { const pos = center(e.touches); this.x = this.visualX = this.x - (lastX - pos.x) / this.zoom; this.y = this.visualY = this.y - (lastY - pos.y) / this.zoom; lastX = pos.x; lastY = pos.y; if(e.touches.length === 2) { const dist = distance(e.touches); const diff = dist / lastDistance; this.zoom = clamp(this.zoom * diff, this.containZoom, Canvas.maxZoom); } this.updateTransform(); }; this.computeLimits(); this.reset(); } private updateTransform() { this.transform.style.transform = `scale3d(${this.visualZoom}, ${this.visualZoom}, 1) translate3d(${this.visualX}px, ${this.visualY}px, 0)`; clearTimeout(this.debouncedTimeout); this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 150); } 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(); }, 50); } protected reset() { this.zoomTo(this.centerX, this.centerY, this.containZoom); } } export class CanvasEditor extends Canvas { private history: History; private selection: Array = []; private nodes!: NodeEditable[]; private edges!: EdgeEditable[]; constructor(history: History, content?: CanvasContent) { super(content); this.history = history; } protected override createDOM() { this.nodes = this.content.nodes.map(e => new NodeEditable(e)); this.edges = this.content.edges.map(e => new EdgeEditable(e, this.content.nodes)); 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)]), ]) ]); //TODO: --zoom-multiplier dynamic 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' }, [ popper(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')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom avant')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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:reload')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Reset')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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.zoomTo(this.x, this.y, this.containZoom); } } }, [icon('radix-icons:corners')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Tout contenir')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom arrière')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), ]), dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [ popper(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')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Annuler (Ctrl+Z)')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Rétablir (Ctrl+Y)')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), ]), dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [ popper(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('radix-icons:gear')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Préférences')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(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('radix-icons:question-mark-circled')]), { delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Aide')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), ]), ]), this.transform, ]); } } 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; } }