obsidian-visualiser/components/CanvasEditor.vue

429 lines
17 KiB
Vue

<script lang="ts">
import type { Position } from '#shared/canvas.util';
type Direction = 'bottom' | 'top' | 'left' | 'right';
const rotation: Record<Direction, string> = {
top: "180",
bottom: "0",
left: "90",
right: "270"
};
interface ActionMap {
move: Position;
edit: string;
resize: string;
}
type Action = keyof ActionMap;
interface HistoryAction<T extends Action = Action>
{
event: T;
element: number | number[];
from: ActionMap[T];
to: ActionMap[T];
}
const cancelEvent = (e: Event) => e.preventDefault();
const stopPropagation = (e: Event) => e.stopImmediatePropagation();
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);
}
function contains(group: CanvasNode, node: CanvasNode): boolean
{
return group.x < node.x && group.y < node.y && group.x + group.width > node.x + node.width && group.y + group.height > node.y + node.height;
}
</script>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.utils';
import type { CanvasContent, CanvasNode } from '~/types/canvas';
import { labelCenter, getPath } from '#shared/canvas.util';
const canvas = defineModel<CanvasContent>({ required: true, });
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
const focusing = ref<number>(), editing = ref<number>();
const canvasRef = useTemplateRef('canvasRef');
const nodes = useTemplateRef('nodes');
const edges = computed(() => {
return canvas.value.edges.map(e => {
const from = canvas.value.nodes.find(f => f.id === e.fromNode), to = canvas.value.nodes.find(f => f.id === e.toNode);
const path = getPath(from!, e.fromSide, to!, e.toSide)!;
return { ...e, from, to, path };
});
});
const history = ref<HistoryAction[]>([]);
const historyPos = ref(-1);
const lastActiveAction = computed(() => history.value.length > 0 && historyPos.value > -1 ? history.value[historyPos.value] : undefined);
const reset = (_: MouseEvent) => {
zoom.value = minZoom.value;
dispX.value = 0;
dispY.value = 0;
}
function addAction<T extends Action = Action>(event: Action, element: number | number[], from: ActionMap[T], to: ActionMap[T])
{
historyPos.value++;
history.value.splice(historyPos.value, history.value.length - historyPos.value);
history.value[historyPos.value] = { event, element, from, to };
}
onMounted(() => {
let lastX = 0, lastY = 0, lastDistance = 0;
let box = canvasRef.value?.getBoundingClientRect()!;
const dragMove = (e: MouseEvent) => {
dispX.value -= (lastX - e.clientX) / zoom.value;
dispY.value -= (lastY - e.clientY) / zoom.value;
lastX = e.clientX;
lastY = e.clientY;
};
const dragEnd = (e: MouseEvent) => {
window.removeEventListener('mouseup', dragEnd);
window.removeEventListener('mousemove', dragMove);
};
canvasRef.value?.addEventListener('mouseenter', () => {
window.addEventListener('wheel', cancelEvent, { passive: false });
document.addEventListener('gesturestart', cancelEvent);
document.addEventListener('gesturechange', cancelEvent);
canvasRef.value?.addEventListener('mouseleave', () => {
window.removeEventListener('wheel', cancelEvent);
document.removeEventListener('gesturestart', cancelEvent);
document.removeEventListener('gesturechange', cancelEvent);
});
})
window.addEventListener('resize', () => box = canvasRef.value?.getBoundingClientRect()!);
canvasRef.value?.addEventListener('mousedown', (e) => {
if(e.button === 1)
{
lastX = e.clientX;
lastY = e.clientY;
window.addEventListener('mouseup', dragEnd, { passive: true });
window.addEventListener('mousemove', dragMove, { passive: true });
}
}, { passive: true });
canvasRef.value?.addEventListener('wheel', (e) => {
if((zoom.value >= 3 && e.deltaY < 0) || (zoom.value <= minZoom.value && e.deltaY > 0))
return;
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;
dispX.value -= mousex / (diff * zoom.value) - mousex / zoom.value;
dispY.value -= mousey / (diff * zoom.value) - mousey / zoom.value;
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
}, { passive: true });
canvasRef.value?.addEventListener('touchstart', (e) => {
({ x: lastX, y: lastY } = center(e.touches));
if(e.touches.length > 1)
{
lastDistance = distance(e.touches);
}
canvasRef.value?.addEventListener('touchend', touchend, { passive: true });
canvasRef.value?.addEventListener('touchcancel', touchcancel, { passive: true });
canvasRef.value?.addEventListener('touchmove', touchmove, { passive: true });
}, { passive: true });
const touchend = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchcancel = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchmove = (e: TouchEvent) => {
const pos = center(e.touches);
dispX.value -= (lastX - pos.x) / zoom.value;
dispY.value -= (lastY - pos.y) / zoom.value;
lastX = pos.x;
lastY = pos.y;
if(e.touches.length === 2)
{
const dist = distance(e.touches);
const diff = lastDistance / dist;
zoom.value = clamp(zoom.value * diff, minZoom.value, 3); //@TODO
}
};
});
function move(index: number, x: number, y: number)
{
const node = canvas.value.nodes[index];
const oldx = node.x, oldy = node.y;
forElements(index, (e) => {
e.x -= x / zoom.value;
e.y -= y / zoom.value;
});
if(lastActiveAction.value && lastActiveAction.value.event === 'move' && lastActiveAction.value.element === index)
{
const action = lastActiveAction.value as HistoryAction<'move'>;
action.to.x -= x / zoom.value;
action.to.y -= y / zoom.value;
}
else
{
addAction('move', index, { x: oldx, y: oldy }, { x: canvas.value.nodes[index].x, y: canvas.value.nodes[index].y });
}
}
function select(node: CanvasNode, index: number, event: Event)
{
if(focusing.value !== index)
{
unselect();
}
nodes.value![index]?.dom?.addEventListener('click', stopPropagation);
canvasRef.value?.addEventListener('click', unselect, { once: true });
focusing.value = index;
}
function edit(node: CanvasNode, index: number, event: Event)
{
nodes.value![index]?.dom?.addEventListener('wheel', stopPropagation);
canvasRef.value?.addEventListener('click', unselect, { once: true });
editing.value = index;
}
const unselect = () => {
if(focusing.value !== undefined)
{
nodes.value![focusing.value]?.dom?.removeEventListener('click', stopPropagation);
nodes.value![focusing.value]?.unselect();
}
focusing.value = undefined;
if(editing.value !== undefined)
{
debugger;
nodes.value![editing.value]?.dom?.removeEventListener('wheel', stopPropagation);
nodes.value![editing.value]?.dom?.removeEventListener('click', stopPropagation);
nodes.value![editing.value]?.unselect();
}
editing.value = undefined;
};
const undo = () => {
if(!lastActiveAction.value)
return;
switch(lastActiveAction.value.event)
{
case 'move':
{
const action = lastActiveAction.value as HistoryAction<'move'>;
const x = action.to.x - action.from.x, y = action.to.y - action.from.y;
forElements(action.element, (e) => {
e.x -= x;
e.y -= y;
});
break;
}
case 'edit':
{
const action = lastActiveAction.value as HistoryAction<'edit'>;
forElements(action.element, (e) => {
e.text = action.from;
});
break;
}
}
historyPos.value--;
};
const redo = () => {
if(!history.value || history.value.length - 1 <= historyPos.value)
return;
historyPos.value++;
if(!lastActiveAction.value)
{
historyPos.value--;
return;
}
switch(lastActiveAction.value.event)
{
case 'move':
{
const action = lastActiveAction.value as HistoryAction<'move'>;
const x = action.from.x - action.to.x, y = action.from.y - action.to.y;
forElements(action.element, (e) => {
e.x -= x;
e.y -= y;
});
break;
}
case 'edit':
{
const action = lastActiveAction.value as HistoryAction<'edit'>;
forElements(action.element, (e) => {
e.text = action.to;
});
break;
}
}
};
useShortcuts({
meta_z: undo,
meta_y: redo,
})
function forElements(element: number | number[], fn: (e: CanvasNode) => void)
{
if(Array.isArray(element))
{
for(const e of element)
{
fn(canvas.value.nodes[e]);
}
}
else
{
fn(canvas.value.nodes[element]);
}
}
</script>
<template>
<div ref="canvasRef" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none" :style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Zoom avant" side="right">
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3)" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:plus" />
</div>
</Tooltip>
<Tooltip message="Reset" side="right">
<div @click="zoom = 1" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:reload" />
</div>
</Tooltip>
<Tooltip message="Tout contenir" side="right">
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:corners" />
</div>
</Tooltip>
<Tooltip message="Zoom arrière" side="right">
<div @click="zoom = clamp(zoom * 0.9, minZoom, 3)" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:minus" />
</div>
</Tooltip>
</div>
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Aide" side="right">
<Dialog title="Aide" iconClose>
<template #trigger>
<div class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:question-mark-circled" />
</div>
</template>
<template #default>
<div class="flex flex-row justify-between px-4">
<div class="flex flex-col gap-2">
<ProseH4>Ordinateur</ProseH4>
<div class="flex items-center"><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/>: Selectionner</div>
<div class="flex items-center"><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/>: Modifier</div>
<div class="flex items-center"><Icon icon="ph:mouse-middle-click-fill" class="w-6 h-6"/>: Déplacer</div>
<div class="flex items-center"><Icon icon="ph:mouse-right-click-fill" class="w-6 h-6"/>: Menu</div>
</div>
<div class="flex flex-col gap-2">
<ProseH4>Mobile</ProseH4>
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/>: Selectionner</div>
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/><Icon icon="ph:hand-tap" class="w-6 h-6"/>: Modifier</div>
<div class="flex items-center"><Icon icon="mdi:gesture-pinch" class="w-6 h-6"/>: Zoomer</div>
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/> maintenu: Menu</div>
</div>
</div>
</template>
</Dialog>
</Tooltip>
</div>
</div>
<!-- <ContextMenuRoot>
<ContextMenuTrigger asChild> -->
<div :style="{
'--tw-translate-x': `${dispX}px`,
'--tw-translate-y': `${dispY}px`,
'--tw-scale': `${zoom}`,
'transform': 'scale3d(var(--tw-scale), var(--tw-scale), 1) translate3d(var(--tw-translate-x), var(--tw-translate-y), 0)',
'transform-origin': 'center center',
}" class="h-full">
<div class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
<div>
<CanvasNodeEditor v-for="(node, index) of canvas.nodes" :key="node.id" ref="nodes" :node="node" :index="index" @select="select" @edit="edit" @move="move"/>
</div>
<template v-for="edge of edges">
<div :key="edge.id" v-if="edge.label" class="absolute z-10"
:style="{ transform: labelCenter(edge.from!, edge.fromSide, edge.to!, edge.toSide) }">
<div class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 -translate-x-[50%] -translate-y-[50%]">{{ edge.label }}</div>
</div>
</template>
<svg class="absolute top-0 left-0 overflow-visible w-full h-full origin-top pointer-events-none">
<g v-for="edge of edges" :key="edge.id" :style="{'--canvas-color': edge.color?.hex}" class="z-0">
<path :style="`stroke-linecap: butt; stroke-width: calc(3px * var(--zoom-multiplier));`" :class="edge.color?.class ? `stroke-light-${edge.color.class} dark:stroke-dark-${edge.color.class}` : ((edge.color && edge.color?.hex !== undefined) ? 'stroke-[color:var(--canvas-color)]' : 'stroke-light-40 dark:stroke-dark-40')" class="fill-none stroke-[4px]" :d="edge.path.path"></path>
<g :style="`transform: translate(${edge.path.to.x}px, ${edge.path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[edge.path.side]}deg);`">
<polygon :class="edge.color?.class ? `fill-light-${edge.color.class} dark:fill-dark-${edge.color.class}` : ((edge.color && edge.color?.hex !== undefined) ? 'fill-[color:var(--canvas-color)]' : 'fill-light-40 dark:fill-dark-40')" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
</g>
</g>
</svg>
</div>
</div><!--
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent>
<ContextMenuItem @select="(e) => canvas.value.nodes.push({ id: useId(), })" >Nouveau</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot> -->
</div>
</template>