Progressing on canvas editor class

This commit is contained in:
Clément Pons 2025-04-22 09:06:45 +02:00
parent 2c80cb2456
commit 9ca546f490
12 changed files with 623 additions and 186 deletions

View File

@ -69,7 +69,7 @@ const hints = ref<SnapHint[]>([]);
const viewport = computed<Box>(() => {
const width = viewportSize.width.value / zoom.value, height = viewportSize.height.value / zoom.value;
const movementX = viewportSize.width.value - width, movementY = viewportSize.height.value - height;
return { x: -dispX.value + movementX / 2, y: -dispY.value + movementY / 2, w: width, h: height };
return { x: -dispX.value + movementX / 2, y: -dispY.value + movementY / 2, width, height };
});
const updateScaleVar = useDebounceFn(() => {
if(transformRef.value)

View File

@ -85,7 +85,7 @@ function editNode(e: Event) {
dom.value?.removeEventListener('mousedown', dragstart);
emit('edit', { type: 'node', id: node.id });
}
function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
function resizeNode(e: MouseEvent, x: number, y: number, width: number, height: number) {
e.stopImmediatePropagation();
const startx = node.x, starty = node.y, startw = node.width, starth = node.height;
@ -96,15 +96,15 @@ function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
realx = realx + (e.movementX / zoom) * x;
realy = realy + (e.movementY / zoom) * y;
realw = Math.max(realw + (e.movementX / zoom) * w, 64);
realh = Math.max(realh + (e.movementY / zoom) * h, 64);
realw = Math.max(realw + (e.movementX / zoom) * width, 64);
realh = Math.max(realh + (e.movementY / zoom) * height, 64);
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy, width: realw, height: realh }, { x, y, w, h });
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy, width: realw, height: realh }, { x, y, width, height });
node.x = result?.x ?? realx;
node.y = result?.y ?? realy;
node.width = result?.w ?? realw;
node.height = result?.h ?? realh;
node.width = result?.width ?? realw;
node.height = result?.height ?? realh;
};
const resizeend = (e: MouseEvent) => {
if(e.button !== 0)

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -35,7 +35,6 @@ export default defineEventHandler(async (e) => {
return data.content;
}
setResponseStatus(e, 404);
return;
}
catch(_e)

View File

@ -1,12 +1,6 @@
/*import useDatabase from "~/composables/useDatabase";
import type { FileType } from '~/types/content';
import { explorerContentTable } from "~/db/schema";
import { eq, ne } from "drizzle-orm";
const typeMapping: Record<string, FileType> = {
".md": "markdown",
".canvas": "canvas"
};
import useDatabase from "~/composables/useDatabase";
import { projectFilesTable, projectContentTable } from "~/db/schema";
import { eq } from "drizzle-orm";
export default defineTask({
meta: {
@ -16,7 +10,7 @@ export default defineTask({
async run(event) {
try {
const db = useDatabase();
const files = db.select().from(explorerContentTable).where(ne(explorerContentTable.type, 'folder')).all();
const files = db.select().from(projectFilesTable).leftJoin(projectContentTable, eq(projectContentTable.id, projectFilesTable.id)).all();
@ -27,4 +21,4 @@ export default defineTask({
return { result: false, error: e };
}
},
})*/
})

View File

@ -4,12 +4,14 @@ 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";
import { History } from "./history.util";
import { fakeA, link } from "./proses";
import { SnapFinder, SpatialGrid } from "./physics.util";
import type { CanvasPreferences } from "~/types/general";
export type Direction = 'bottom' | 'top' | 'left' | 'right';
export type Position = { x: number, y: number };
export type Box = Position & { w: number, h: number };
export type Box = Position & { width: number, height: number };
export type Path = {
path: string;
from: Position;
@ -173,8 +175,11 @@ export class Node extends EventTarget
}
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() } });
edges: Set<EdgeEditable> = new Set();
private dirty: boolean = false;
constructor(properties: CanvasNode)
{
super(properties);
@ -183,26 +188,9 @@ export class NodeEditable extends Node
{
const style = this.style;
/*
<div class="absolute" ref="dom" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
<div v-if="!editing || node.type === 'group'" style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4 cursor-move': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4">
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto" :class="style.bg" @click.left="(e) => { if(node.type !== 'group') selectNode(e) }" @dblclick.left="(e) => { if(node.type !== 'group') editNode(e) }">
<div v-if="node.text && node.text.length > 0" class="flex items-center">
<MarkdownRenderer :content="node.text" :proses="{ a: FakeA }" />
</div>
</div>
</div>
<div v-else style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 overflow-hidden contain-strict w-full h-full flex py-2" >
<FramedEditor v-model="node.text" autofocus :gutters="false"/>
</div>
<div v-if="!editing && node.type === 'group' && node.label !== undefined" @click.left="(e) => selectNode(e)" @dblclick.left="(e) => editNode(e)" :class="style.border" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" 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">{{ node.label }}</div>
<input v-else-if="editing && node.type === 'group'" v-model="node.label" @click="e => e.stopImmediatePropagation()" v-autofocus :class="[style.border, style.outline]" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" 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" />
</div>
*/
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])
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: fakeA } })]) : undefined])
])
]);
@ -214,6 +202,19 @@ export class NodeEditable extends Node
}
}
}
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()
{
@ -222,6 +223,43 @@ export class NodeEditable extends Node
{ 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
@ -229,20 +267,20 @@ export class Edge extends EventTarget
properties: CanvasEdge;
edgeDom!: HTMLDivElement;
protected from: CanvasNode;
protected to: CanvasNode;
protected from: Node;
protected to: Node;
protected path: Path;
protected labelPos: string;
constructor(properties: CanvasEdge, nodes: CanvasNode[])
constructor(properties: CanvasEdge, from: Node, to: Node)
{
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.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();
}
@ -279,14 +317,16 @@ export class EdgeEditable extends Edge
private pathDom!: SVGPathElement;
private inputDom!: HTMLDivElement;
constructor(properties: CanvasEdge, nodes: CanvasNode[])
constructor(properties: CanvasEdge, from: NodeEditable, to: NodeEditable)
{
super(properties, nodes);
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], 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.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,
@ -301,24 +341,30 @@ export class EdgeEditable extends Edge
]),
]);
}
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 _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 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 visualZoom: number = this._zoom;
protected visualX: number = this._x;
protected visualY: number = this._y;
protected tweener: Tweener = new Tweener();
private debouncedTimeout: Timer = setTimeout(() => {}, 0);
@ -326,6 +372,15 @@ export class Canvas
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)
@ -347,38 +402,31 @@ export class Canvas
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.content.nodes.map(e => new Node(e).nodeDom)]), dom('div', {}, [...this.content.edges.map(e => new Edge(e, this.content.nodes).edgeDom)]),
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')]), {
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')]), {
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: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')]), {
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
]),
//link({}, { name: 'explore-edit' }),
]), this.transform,
]);
/*
<NuxtLink v-if="overview && isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])" :to="{ name: 'explore-edit', hash: `#${encodeURIComponent(overview!.path)}` }" class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Modifier" side="right">
<Icon icon="radix-icons:pencil-1" class="w-8 h-8 p-2" />
</Tooltip>
</NuxtLink>
*/
}
protected computeLimits()
@ -393,26 +441,21 @@ export class Canvas
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.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()
{
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();
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 });
@ -424,32 +467,36 @@ export class Canvas
document.removeEventListener('gesturestart', cancelEvent);
document.removeEventListener('gesturechange', cancelEvent);
});
})
});
this.container.addEventListener('mousedown', (e) => {
lastX = e.layerX;
lastY = e.layerY;
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, { passive: true });
window.addEventListener('mousemove', dragMove, { passive: true });
this.dragStart(e);
}, { passive: true });
this.container.addEventListener('wheel', (e) => {
if((this.zoom >= Canvas.maxZoom && e.deltaY < 0) || (this.zoom <= this.containZoom && e.deltaY > 0))
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 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));
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));
({ x: this.lastX, y: this.lastY } = center(e.touches));
if(e.touches.length > 1)
{
lastDistance = distance(e.touches);
this.lastDistance = distance(e.touches);
}
this.container.addEventListener('touchend', touchend, { passive: true });
@ -459,7 +506,7 @@ export class Canvas
const touchend = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
({ x: this.lastX, y: this.lastY } = center(e.touches));
}
this.container.removeEventListener('touchend', touchend);
@ -469,7 +516,7 @@ export class Canvas
const touchcancel = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
({ x: this.lastX, y: this.lastY } = center(e.touches));
}
this.container.removeEventListener('touchend', touchend);
@ -478,17 +525,17 @@ export class Canvas
};
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;
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 / lastDistance;
const diff = dist / this.lastDistance;
this.zoom = clamp(this.zoom * diff, this.containZoom, Canvas.maxZoom);
this._zoom = clamp(this._zoom * diff, this.containZoom, Canvas.maxZoom);
}
this.updateTransform();
@ -498,7 +545,7 @@ export class Canvas
this.reset();
}
private updateTransform()
protected updateTransform()
{
this.transform.style.transform = `scale3d(${this.visualZoom}, ${this.visualZoom}, 1) translate3d(${this.visualX}px, ${this.visualY}px, 0)`;
@ -514,10 +561,10 @@ export class Canvas
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;
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);
@ -532,44 +579,144 @@ export class Canvas
{
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();
}
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 selection: Array<NodeEditable | EdgeEditable> = [];
private focused: NodeEditable | EdgeEditable | undefined;
private selection: Set<NodeEditable> = new Set();
private nodes!: NodeEditable[];
private edges!: EdgeEditable[];
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)' } });
constructor(history: History, content?: CanvasContent)
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.history = history;
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()
{
this.nodes = this.content.nodes.map(e => new NodeEditable(e));
this.edges = this.content.edges.map(e => new EdgeEditable(e, this.content.nodes));
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)]), dom('div', {}, [...this.edges.map(e => e.edgeDom)]),
])
dom('div', {}, [...this.nodes.map(e => e.nodeDom), this.nodeHelper]), dom('div', {}, [...this.edges.map(e => e.edgeDom)]),
]), this.edgeHelper,
]);
//TODO: --zoom-multiplier dynamic
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none' }, [
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' }, [
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')]), {
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')]), {
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.reset() } }, [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')]), {
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'
}),
]),
@ -582,16 +729,229 @@ export class CanvasEditor extends Canvas
}),
]),
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')]), {
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: () => {} } }, [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')]), {
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: () => {} } }, [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,
]), 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()
{
super.updateTransform();
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

View File

@ -1,5 +1,5 @@
import { safeDestr as parse } from 'destr';
import { Canvas } from "#shared/canvas.util";
import { Canvas, CanvasEditor } from "#shared/canvas.util";
import render from "#shared/markdown.util";
import { confirm, contextmenu, popper } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
@ -129,6 +129,7 @@ export class Content
return false;
Content.root = await navigator.storage.getDirectory();
Content.root.getDirectoryHandle('storage', { create: true });
const overview = await Content.read('overview', { create: true });
try
@ -153,11 +154,11 @@ export class Content
return Content.initPromise;
}
static getFromPath(path: string, content: boolean = false)
static getFromPath(path: string)
{
return Object.values(Content._overview).find(e => e.path === path);
}
static get(id: string, content: boolean = false)
static get(id: string)
{
return Content._overview[id];
}
@ -238,13 +239,16 @@ export class Content
{
Content._overview[file.id] = file;
if(file.type === 'folder')
continue;
Content.queue.queue(() => {
return useRequestFetch()(`/api/file/content/${file.id}`, { cache: 'no-cache' }).then(async (content: string | undefined) => {
if(content)
Content.queue.queue(() => Content.write(file.id, content, { create: true }));
{
if(file.type !== 'folder')
{
Content.queue.queue(() => Content.write(file.id, content, { create: true }));
Content.queue.queue(() => Content.write('storage/' + file.path + (file.type === 'canvas' ? '.canvas' : '.md'), Content.toString({ ...file, content: Content.fromString(file, content) }), { create: true }));
}
}
else
Content._overview[file.id].error = true;
}).catch(e => {
@ -293,6 +297,23 @@ export class Content
console.timeEnd(`Reading '${path}'`);
}
}
private static async goto(path: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle | undefined>
{
const splitPath = path.split("/");
let handle = Content.root;
try
{
for(const p of splitPath)
{
handle = await handle.getDirectoryHandle(p, options);
}
return handle;
}
catch(e)
{
return undefined;
}
}
//Easy to use, but not very performant.
private static async write(path: string, content: string, options?: FileSystemGetFileOptions): Promise<void>
{
@ -300,7 +321,8 @@ export class Content
console.time(`Writing ${size} bytes to '${path}'`);
try
{
const handle = await Content.root.getFileHandle(path, options);
const parent = path.split('/').slice(0, -1).join('/'), basename = path.split('/').slice(-1).join('/');
const handle = await (await Content.goto(parent, { create: true }) ?? Content.root).getFileHandle(basename, options);
const file = await handle.createWritable({ keepExistingData: false });
await file.write(content);
@ -403,16 +425,58 @@ type ContentTypeHandler<T extends FileType> = {
renderEditor: (content: LocalContent<T>) => Node;
};
function reshapeLinks(content: string | null, all: ProjectContent[])
{
return content?.replace(/\[\[(.*?)?(#.*?)?(\|.*?)?\]\]/g, (str, link, header, title) => {
return `[[${link ? parsePath(all.find(e => e.path.endsWith(parsePath(link)))?.path ?? parsePath(link)) : ''}${header ?? ''}${title ?? ''}]]`;
});
}
const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
canvas: {
toString: (content) => JSON.stringify(content),
toString: (content) => {
const mapping: Record<string, string> = {
'red': '1',
'orange': '2',
'yellow': '3',
'green': '4',
'cyan': '5',
'purple': '6',
};
content.edges?.forEach(e => e.color = e.color ? e.color.hex ?? (e.color.class ? mapping[e.color.class]! : undefined) : undefined);
content.nodes?.forEach(e => e.color = e.color ? e.color.hex ?? (e.color.class ? mapping[e.color.class]! : undefined) : undefined);
return JSON.stringify(content);
},
fromString: (str) => JSON.parse(str),
render: (content) => {
const c = new Canvas(content.content);
queueMicrotask(() => c.mount());
return c.container;
},
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
renderEditor: (content) => {
let element: HTMLElement;
if(content.hasOwnProperty('content'))
{
const c = new CanvasEditor(content.content);
queueMicrotask(() => c.mount());
element = c.container;
}
else
{
element = loading("large");
Content.getContent(content.id).then(e => {
if(!e)
return element.parentElement?.replaceChild(dom('div', { class: '', text: 'Une erreur est survenue.' }), element);
content.content = e.content as CanvasContent;
const c = new CanvasEditor(content.content);
queueMicrotask(() => c.mount());
element.parentElement?.replaceChild(c.container, element);
});
}
return element;
},
},
markdown: {
toString: (content) => content,
@ -624,6 +688,8 @@ export class Editor
this.cleanup = this.setupDnD();
this.container = dom('div', { class: 'flex flex-1 flex-col items-start justify-start max-h-full relative' }, [dom('div', { class: 'py-4 flex-1 w-full max-h-full flex overflow-auto xl:px-12 lg:px-8 px-6 relative' })]);
this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive<LocalContent & { element?: HTMLElement }> | undefined);
}
private contextmenu(e: MouseEvent, item: Recursive<LocalContent>)
{
@ -812,6 +878,8 @@ export class Editor
this.selected = item;
}
useRouter().push({ hash: this.selected ? '#' + this.selected.id : '' })
this.container.firstElementChild!.replaceChildren();
this.selected && this.container.firstElementChild!.appendChild(this.render(this.selected) as HTMLElement);
}

View File

@ -43,8 +43,9 @@ export default function(content: string, filter?: string, properties?: MDPropert
{
const load = loading('normal');
useMarkdown().parse(content).then(data => {
if(filter)
queueMicrotask(() => {
useMarkdown().parse(content).then(data => {
if(filter)
{
const start = data?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
@ -67,7 +68,8 @@ export default function(content: string, filter?: string, properties?: MDPropert
if(properties) styling(el, properties);
load.parentElement?.replaceChild(el, load);
});
});
})
return load;
}

View File

@ -1,6 +1,6 @@
import type { CanvasContent, CanvasNode } from "~/types/canvas";
import type { CanvasPreferences } from "~/types/general";
import type { Position, Box, Direction } from "./canvas.util";
import type { Position, Box, Direction, CanvasEditor } from "./canvas.util";
interface SnapPoint {
pos: Position;
@ -26,8 +26,20 @@ const enum TYPE {
EDGE,
}
class SpatialGrid {
private cells: Map<number, Map<number, Set<string>>> = new Map();
export function overlapsBoxes(a: Box, b: Box): boolean
{
return overlaps(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height);
}
export function overlaps(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean
{
return !(bx > (ax + aw)
|| (bx + bw) < ax
|| by > (ay + ah)
|| (by + bh) < ay);
}
export class SpatialGrid<T extends Box> {
private cells: Map<number, Map<number, Set<T>>> = new Map();
private cellSize: number;
private minx: number = Infinity;
@ -35,20 +47,23 @@ class SpatialGrid {
private maxx: number = -Infinity;
private maxy: number = -Infinity;
private cacheSet: Set<string> = new Set<string>();
private cacheSet: Set<T> = new Set<T>();
constructor(cellSize: number) {
constructor(cellSize: number)
{
this.cellSize = cellSize;
}
private updateBorders(startX: number, startY: number, endX: number, endY: number) {
private updateBorders(startX: number, startY: number, endX: number, endY: number)
{
this.minx = Math.min(this.minx, startX);
this.miny = Math.min(this.miny, startY);
this.maxx = Math.max(this.maxx, endX);
this.maxy = Math.max(this.maxy, endY);
}
insert(node: CanvasNode): void {
insert(node: T): void
{
const startX = Math.floor(node.x / this.cellSize);
const startY = Math.floor(node.y / this.cellSize);
const endX = Math.ceil((node.x + node.width) / this.cellSize);
@ -59,22 +74,22 @@ class SpatialGrid {
for (let i = startX; i <= endX; i++) {
let gridX = this.cells.get(i);
if (!gridX) {
gridX = new Map<number, Set<string>>();
gridX = new Map<number, Set<T>>();
this.cells.set(i, gridX);
}
for (let j = startY; j <= endY; j++) {
let gridY = gridX.get(j);
if (!gridY) {
gridY = new Set<string>();
gridY = new Set<T>();
gridX.set(j, gridY);
}
gridY.add(node.id);
gridY.add(node);
}
}
}
remove(node: CanvasNode): void {
remove(node: T): void {
const startX = Math.floor(node.x / this.cellSize);
const startY = Math.floor(node.y / this.cellSize);
const endX = Math.ceil((node.x + node.width) / this.cellSize);
@ -84,17 +99,17 @@ class SpatialGrid {
const gridX = this.cells.get(i);
if (gridX) {
for (let j = startY; j <= endY; j++) {
gridX.get(j)?.delete(node.id);
gridX.get(j)?.delete(node);
}
}
}
}
fetch(x: number, y: number): Set<string> | undefined {
fetch(x: number, y: number): Set<T> | undefined {
return this.query(x, y, x, y);
}
query(x1: number, y1: number, x2: number, y2: number): Set<string> {
query(x1: number, y1: number, x2: number, y2: number): Set<T> {
this.cacheSet.clear();
const startX = Math.floor(x1 / this.cellSize);
@ -108,7 +123,7 @@ class SpatialGrid {
for (let dy = startY; dy <= endY; dy++) {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => this.cacheSet.add(neighbor));
cellNodes.forEach(neighbor => !this.cacheSet.has(neighbor) && (overlaps(x1, y1, x2 - x1, y2 - y1, neighbor.x, neighbor.y, neighbor.width, neighbor.height)) && this.cacheSet.add(neighbor));
}
}
}
@ -117,7 +132,7 @@ class SpatialGrid {
return this.cacheSet;
}
getViewportNeighbors(node: CanvasNode, viewport?: Box): Set<string> {
getViewportNeighbors(node: T, viewport?: Box): Set<T> {
this.cacheSet.clear();
const startX = Math.floor(node.x / this.cellSize);
@ -127,8 +142,8 @@ class SpatialGrid {
const minX = Math.max(viewport ? Math.max(this.minx, Math.floor(viewport.x / this.cellSize)) : this.minx, startX - 8);
const minY = Math.max(viewport ? Math.max(this.miny, Math.floor(viewport.y / this.cellSize)) : this.miny, startY - 8);
const maxX = Math.min(viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.w) / this.cellSize)) : this.maxx, endX + 8);
const maxY = Math.min(viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.h) / this.cellSize)) : this.maxy, endY + 8);
const maxX = Math.min(viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.width) / this.cellSize)) : this.maxx, endX + 8);
const maxY = Math.min(viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.height) / this.cellSize)) : this.maxy, endY + 8);
for (let dx = minX; dx <= maxX; dx++) {
const gridX = this.cells.get(dx);
@ -137,7 +152,7 @@ class SpatialGrid {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => {
if (neighbor !== node.id) this.cacheSet.add(neighbor);
if (neighbor !== node) this.cacheSet.add(neighbor);
});
}
}
@ -150,7 +165,7 @@ class SpatialGrid {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => {
if (neighbor !== node.id) this.cacheSet.add(neighbor);
if (neighbor !== node) this.cacheSet.add(neighbor);
});
}
}
@ -200,34 +215,33 @@ class SnapPointCache {
}
export class SnapFinder {
private spatialGrid: SpatialGrid;
private spatialGrid: SpatialGrid<CanvasNode>;
private snapPointCache: SnapPointCache;
config: SnapConfig;
private config: SnapConfig;
hints: Ref<SnapHint[]>;
viewport: Ref<Box>;
private canvas: CanvasEditor;
private hints: SnapHint[] = [];
constructor(hints: Ref<SnapHint[]>, viewport: Ref<Box>, config: SnapConfig) {
constructor(canvas: CanvasEditor, config: SnapConfig) {
this.spatialGrid = new SpatialGrid(config.cellSize);
this.snapPointCache = new SnapPointCache();
this.config = config;
this.hints = hints;
this.viewport = viewport;
this.canvas = canvas;
}
add(node: CanvasNode): void
{
this.spatialGrid.insert(node);
this.snapPointCache.insert(node);
this.hints.value.length = 0;
this.hints.length = 0;
}
remove(node: CanvasNode): void
{
this.spatialGrid.remove(node);
this.snapPointCache.invalidate(node);
this.hints.value.length = 0;
this.hints.length = 0;
}
update(node: CanvasNode): void
@ -257,26 +271,26 @@ export class SnapFinder {
const result: Partial<Box> = {
x: undefined,
y: undefined,
w: undefined,
h: undefined,
width: undefined,
height: undefined,
};
if(!this.config.preferences.neighborSnap)
{
result.x = this.snapToGrid(node.x);
result.w = this.snapToGrid(node.width);
result.width = this.snapToGrid(node.width);
result.y = this.snapToGrid(node.y);
result.h = this.snapToGrid(node.height);
result.height = this.snapToGrid(node.height);
return result;
}
this.hints.value.length = 0;
this.hints.length = 0;
this.snapPointCache.invalidate(node);
this.snapPointCache.insert(node);
const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.viewport.value)].flatMap(e => this.snapPointCache.getSnapPoints(e)).filter(e => !!e);
const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.canvas.viewport)].flatMap(e => this.snapPointCache.getSnapPoints(e)).filter(e => !!e);
const bestSnap = this.findBestSnap(this.snapPointCache.getSnapPoints(node.id)!, neighbors, this.config.threshold, resizeHandle);
return this.applySnap(node, bestSnap.x, bestSnap.y, resizeHandle);
@ -323,7 +337,7 @@ export class SnapFinder {
yHints.forEach(e => e.start.x += bestSnap.x!);
}
this.hints.value = [...xHints, ...yHints];
this.hints = [...xHints, ...yHints];
return bestSnap;
}
@ -335,14 +349,14 @@ export class SnapFinder {
private applySnap(node: CanvasNode, offsetx?: number, offsety?: number, resizeHandle?: Box): Partial<Box>
{
const result: Partial<Box> = { x: undefined, y: undefined, w: undefined, h: undefined };
const result: Partial<Box> = { x: undefined, y: undefined, width: undefined, height: undefined };
if (resizeHandle)
{
result.x = offsetx ? node.x + offsetx * resizeHandle.x : this.snapToGrid(node.x);
result.w = offsetx ? node.width + offsetx * resizeHandle.w : this.snapToGrid(node.width);
result.w = offsetx ? node.width + offsetx * resizeHandle.width : this.snapToGrid(node.width);
result.y = offsety ? node.y + offsety * resizeHandle.y : this.snapToGrid(node.y);
result.h = offsety ? node.height - offsety * resizeHandle.h : this.snapToGrid(node.height);
result.h = offsety ? node.height - offsety * resizeHandle.height : this.snapToGrid(node.height);
}
else
{