Add edge editor, generalize selection and edition to both node and edge. Still trying to find a proper tween.

This commit is contained in:
Peaceultime 2025-01-14 17:57:57 +01:00
parent 76db788192
commit 348c991c54
6 changed files with 329 additions and 214 deletions

View File

@ -1,22 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Box, Position } from '#shared/canvas.util'; import type { Position } from '#shared/canvas.util';
import type CanvasNodeEditor from './canvas/CanvasNodeEditor.vue'; import type CanvasNodeEditor from './canvas/CanvasNodeEditor.vue';
type Direction = 'bottom' | 'top' | 'left' | 'right'; import type CanvasEdgeEditor from './canvas/CanvasEdgeEditor.vue';
const rotation: Record<Direction, string> = { export type Element = { type: 'node' | 'edge', id: string };
top: "180",
bottom: "0",
left: "90",
right: "270"
};
type Element = { type: 'node' | 'edge', id: string };
interface ActionMap { interface ActionMap {
move: Position; remove: CanvasNode | CanvasEdge | undefined;
edit: string; create: CanvasNode | CanvasEdge | undefined;
resize: Box; property: CanvasNode | CanvasEdge;
remove: CanvasNode | undefined;
create: CanvasNode | undefined;
property: CanvasNode;
} }
type Action = keyof ActionMap; type Action = keyof ActionMap;
interface HistoryEvent<T extends Action = Action> interface HistoryEvent<T extends Action = Action>
@ -26,11 +17,16 @@ interface HistoryEvent<T extends Action = Action>
} }
interface HistoryAction<T extends Action> interface HistoryAction<T extends Action>
{ {
element: string; element: Element;
from: ActionMap[T]; from: ActionMap[T];
to: ActionMap[T]; to: ActionMap[T];
} }
type NodeEditor = InstanceType<typeof CanvasNodeEditor>;
type EdgeEditor = InstanceType<typeof CanvasEdgeEditor>;
const TWEEN_DURATION = 200;
const cancelEvent = (e: Event) => e.preventDefault(); const cancelEvent = (e: Event) => e.preventDefault();
const stopPropagation = (e: Event) => e.stopImmediatePropagation(); const stopPropagation = (e: Event) => e.stopImmediatePropagation();
function getID(length: number) function getID(length: number)
@ -67,27 +63,18 @@ function contains(group: CanvasNode, node: CanvasNode): boolean
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.utils'; import { clamp } from '#shared/general.utils';
import type { CanvasContent, CanvasNode } from '~/types/canvas'; import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas';
import { labelCenter, getPath } from '#shared/canvas.util';
const canvas = defineModel<CanvasContent>({ required: true, }); const canvas = defineModel<CanvasContent>({ required: true, });
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5); const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
const focusing = ref<string>(), editing = ref<string>(); const focusing = ref<Element>(), editing = ref<Element>();
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef'); const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
const nodes = useTemplateRef<InstanceType<typeof CanvasNodeEditor>[]>('nodes'); const nodes = useTemplateRef<NodeEditor[]>('nodes'), edges = useTemplateRef<EdgeEditor[]>('edges');
const xTween = useTween(dispX, linear, updateTransform), yTween = useTween(dispY, linear, updateTransform), zoomTween = useTween(zoom, linear, updateTransform); const xTween = useTween(dispX, linear, updateTransform), yTween = useTween(dispY, linear, updateTransform), zoomTween = useTween(zoom, linear, updateTransform);
const focusedNode = computed(() => nodes.value?.find(e => !!e && e.id === focusing.value)), editedNode = computed(() => nodes.value?.find(e => !!e && e.id === editing.value)); const focused = computed(() => focusing.value ? focusing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === focusing.value!.id) : edges.value?.find(e => !!e && e.id === focusing.value!.id) : undefined), edited = computed(() => editing.value ? editing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === editing.value!.id) : edges.value?.find(e => !!e && e.id === editing.value!.id) : undefined);
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<HistoryEvent[]>([]); const history = ref<HistoryEvent[]>([]);
const historyPos = ref(-1); const historyPos = ref(-1);
@ -159,10 +146,10 @@ onMounted(() => {
const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2); const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2);
const mousex = centerX - e.clientX, mousey = centerY - e.clientY; const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
xTween.update(dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value), 250); xTween.update(dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value), TWEEN_DURATION);
yTween.update(dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value), 250); yTween.update(dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value), TWEEN_DURATION);
zoomTween.update(clamp(zoom.value * diff, minZoom.value, 3), 250); zoomTween.update(clamp(zoom.value * diff, minZoom.value, 3), TWEEN_DURATION);
updateTransform(); updateTransform();
}, { passive: true }); }, { passive: true });
@ -233,47 +220,53 @@ function updateTransform()
} }
function moveNode(ids: string[], deltax: number, deltay: number) function moveNode(ids: string[], deltax: number, deltay: number)
{ {
const actions: HistoryAction<'move'>[] = []; if(ids.length === 0)
return;
const actions: HistoryAction<'property'>[] = [];
for(const id of ids) for(const id of ids)
{ {
const node = canvas.value.nodes!.find(e => e.id === id)!; const node = canvas.value.nodes!.find(e => e.id === id)!;
actions.push({ element: id, from: { x: node.x - deltax, y: node.y - deltay }, to: { x: node.x, y: node.y } }); actions.push({ element: { type: 'node', id }, from: { ...node, x: node.x - deltax, y: node.y - deltay }, to: { ...node } });
} }
addAction('move', actions); addAction('property', actions);
} }
function resizeNode(ids: string[], deltax: number, deltay: number, deltaw: number, deltah: number) function resizeNode(ids: string[], deltax: number, deltay: number, deltaw: number, deltah: number)
{ {
const actions: HistoryAction<'resize'>[] = []; if(ids.length === 0)
return;
const actions: HistoryAction<'property'>[] = [];
for(const id of ids) for(const id of ids)
{ {
const node = canvas.value.nodes!.find(e => e.id === id)!; const node = canvas.value.nodes!.find(e => e.id === id)!;
actions.push({ element: id, from: { x: node.x - deltax, y: node.y - deltay, w: node.width - deltaw, h: node.height - deltah }, to: { x: node.x, y: node.y, w: node.width, h: node.height } }); actions.push({ element: { type: 'node', id }, from: { ...node, x: node.x - deltax, y: node.y - deltay, width: node.width - deltaw, height: node.height - deltah }, to: { ...node } });
} }
addAction('resize', actions); addAction('property', actions);
} }
function selectNode(id: string) function select(element: Element)
{ {
if(focusing.value !== id) if(focusing.value && (focusing.value.id !== element.id || focusing.value.type !== element.type))
{ {
unselectNode(); unselect();
} }
focusing.value = id; focusing.value = element;
focusedNode.value?.dom?.addEventListener('click', stopPropagation, { passive: true }); focused.value?.dom?.addEventListener('click', stopPropagation, { passive: true });
canvasRef.value?.addEventListener('click', unselectNode, { once: true }); canvasRef.value?.addEventListener('click', unselect, { once: true });
} }
function editNode(id: string) function edit(element: Element)
{ {
editing.value = id; editing.value = element;
focusedNode.value?.dom?.addEventListener('wheel', stopPropagation, { passive: true }); focused.value?.dom?.addEventListener('wheel', stopPropagation, { passive: true });
focusedNode.value?.dom?.addEventListener('dblclick', stopPropagation, { passive: true }); focused.value?.dom?.addEventListener('dblclick', stopPropagation, { passive: true });
canvasRef.value?.addEventListener('click', unselectNode, { once: true }); canvasRef.value?.addEventListener('click', unselect, { once: true });
} }
function createNode(e: MouseEvent) function createNode(e: MouseEvent)
{ {
@ -285,49 +278,74 @@ function createNode(e: MouseEvent)
else else
canvas.value.nodes.push(node); canvas.value.nodes.push(node);
addAction('create', [{ element: node.id, from: undefined, to: node }]); addAction('create', [{ element: { type: 'node', id: node.id }, from: undefined, to: node }]);
} }
function removeNode(ids: string[]) function remove(elements: Element[])
{ {
const actions: HistoryAction<'remove'>[] = []; if(elements.length === 0)
unselectNode(); return;
for(const id of ids) const actions: HistoryAction<'remove'>[] = [];
focusing.value = undefined;
editing.value = undefined;
const c = canvas.value;
for(const element of elements)
{ {
const index = canvas.value.nodes!.findIndex(e => e.id === id); if(element.type === 'node')
actions.push({ element: id, from: canvas.value.nodes!.splice(index, 1)[0], to: undefined }); {
const edges = c.edges?.map((e, i) => ({ id: e.id, from: e.fromNode, to: e.toNode, index: i }))?.filter(e => e.from === element.id || e.to === element.id) ?? [];
for(let i = edges.length - 1; i >= 0; i--)
{
actions.push({ element: { type: 'edge', id: edges[i].id }, from: c.edges!.splice(edges[i].index, 1)[0], to: undefined });
} }
const index = c.nodes!.findIndex(e => e.id === element.id);
actions.push({ element: { type: 'node', id: element.id }, from: c.nodes!.splice(index, 1)[0], to: undefined });
}
else if(element.type === 'edge' && !actions.find(e => e.element.type === 'edge' && e.element.id === element.id))
{
const index = c.edges!.findIndex(e => e.id === element.id);
actions.push({ element: { type: 'edge', id: element.id }, from: c.edges!.splice(index, 1)[0], to: undefined });
}
}
canvas.value = c;
addAction('remove', actions); addAction('remove', actions);
} }
function editNodeProperty<T extends keyof CanvasNode>(ids: string[], property: T, value: CanvasNode[T]) function editNodeProperty<T extends keyof CanvasNode>(ids: string[], property: T, value: CanvasNode[T])
{ {
const actions: HistoryAction<'remove'>[] = []; if(ids.length === 0)
return;
const actions: HistoryAction<'property'>[] = [];
for(const id of ids) for(const id of ids)
{ {
const copy = JSON.parse(JSON.stringify(canvas.value.nodes!.find(e => e.id === id)!)) as CanvasNode; const copy = JSON.parse(JSON.stringify(canvas.value.nodes!.find(e => e.id === id)!)) as CanvasNode;
canvas.value.nodes!.find(e => e.id === id)![property] = value; canvas.value.nodes!.find(e => e.id === id)![property] = value;
actions.push({ element: id, from: copy, to: canvas.value.nodes!.find(e => e.id === id)! }); actions.push({ element: { type: 'node', id }, from: copy, to: canvas.value.nodes!.find(e => e.id === id)! });
} }
addAction('property', actions); addAction('property', actions);
} }
const unselectNode = () => { const unselect = () => {
if(focusing.value !== undefined) if(focusing.value !== undefined)
{ {
focusedNode.value?.dom?.removeEventListener('click', stopPropagation); focused.value?.dom?.removeEventListener('click', stopPropagation);
focusedNode.value?.unselect(); focused.value?.unselect();
} }
focusing.value = undefined; focusing.value = undefined;
if(editing.value !== undefined) if(editing.value !== undefined)
{ {
editedNode.value?.dom?.removeEventListener('wheel', stopPropagation); edited.value?.dom?.removeEventListener('wheel', stopPropagation);
editedNode.value?.dom?.removeEventListener('dblclick', stopPropagation); edited.value?.dom?.removeEventListener('dblclick', stopPropagation);
editedNode.value?.dom?.removeEventListener('click', stopPropagation); edited.value?.dom?.removeEventListener('click', stopPropagation);
editedNode.value?.unselect(); edited.value?.unselect();
} }
editing.value = undefined; editing.value = undefined;
}; };
@ -337,57 +355,61 @@ const undo = () => {
for(const action of historyCursor.value.actions) for(const action of historyCursor.value.actions)
{ {
const node = canvas.value.nodes!.find(e => e.id === action.element)!; if(action.element.type === 'node')
{
switch(historyCursor.value.event) switch(historyCursor.value.event)
{ {
case 'move':
{
const a = action as HistoryAction<'move'>;
node.x = a.from.x;
node.y = a.from.y;
break;
}
case 'resize':
{
const a = action as HistoryAction<'resize'>;
node.x = a.from.x;
node.y = a.from.y;
node.width = a.from.w;
node.height = a.from.h;
break;
}
case 'edit':
{
const a = action as HistoryAction<'edit'>;
node.label = a.from;
break;
}
case 'create': case 'create':
{ {
const a = action as HistoryAction<'create'>; const a = action as HistoryAction<'create'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element); const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
canvas.value.nodes!.splice(index, 1); canvas.value.nodes!.splice(index, 1);
break; break;
} }
case 'remove': case 'remove':
{ {
const a = action as HistoryAction<'remove'>; const a = action as HistoryAction<'remove'>;
canvas.value.nodes!.push(a.from!); canvas.value.nodes!.push(a.from as CanvasNode);
break; break;
} }
case 'property': case 'property':
{ {
const a = action as HistoryAction<'property'>; const a = action as HistoryAction<'property'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element); const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
canvas.value.nodes![index] = a.from; canvas.value.nodes![index] = a.from as CanvasNode;
break; break;
} }
} }
} }
else if(action.element.type === 'edge')
{
switch(historyCursor.value.event)
{
case 'create':
{
const a = action as HistoryAction<'create'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges!.splice(index, 1);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
canvas.value.edges!.push(a.from! as CanvasEdge);
break;
}
case 'property':
{
const a = action as HistoryAction<'property'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges![index] = a.from as CanvasEdge;
break;
}
}
}
}
historyPos.value--; historyPos.value--;
console.log(historyPos.value, history.value.length);
}; };
const redo = () => { const redo = () => {
if(!history.value || history.value.length - 1 <= historyPos.value) if(!history.value || history.value.length - 1 <= historyPos.value)
@ -403,61 +425,65 @@ const redo = () => {
for(const action of historyCursor.value.actions) for(const action of historyCursor.value.actions)
{ {
const node = canvas.value.nodes!.find(e => e.id === action.element)!; if(action.element.type === 'node')
{
switch(historyCursor.value.event) switch(historyCursor.value.event)
{ {
case 'move':
{
const a = action as HistoryAction<'move'>;
node.x = a.to.x;
node.y = a.to.y;
break;
}
case 'resize':
{
const a = action as HistoryAction<'resize'>;
node.x = a.to.x;
node.y = a.to.y;
node.width = a.to.w;
node.height = a.to.h;
break;
}
case 'edit':
{
const a = action as HistoryAction<'edit'>;
node.label = a.to;
break;
}
case 'create': case 'create':
{ {
const a = action as HistoryAction<'remove'>; const a = action as HistoryAction<'create'>;
canvas.value.nodes!.push(a.to!); canvas.value.nodes!.push(a.to as CanvasNode);
break; break;
} }
case 'remove': case 'remove':
{ {
const a = action as HistoryAction<'remove'>; const a = action as HistoryAction<'remove'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element); const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
canvas.value.nodes!.splice(index, 1); canvas.value.nodes!.splice(index, 1);
break; break;
} }
case 'property': case 'property':
{ {
const a = action as HistoryAction<'property'>; const a = action as HistoryAction<'property'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element); const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
canvas.value.nodes![index] = a.to; canvas.value.nodes![index] = a.to as CanvasNode;
break; break;
} }
} }
} }
else if(action.element.type === 'edge')
console.log(historyPos.value, history.value.length); {
switch(historyCursor.value.event)
{
case 'create':
{
const a = action as HistoryAction<'create'>;
canvas.value.edges!.push(a.to as CanvasEdge);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges!.splice(index, 1);
break;
}
case 'property':
{
const a = action as HistoryAction<'property'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges![index] = a.to as CanvasEdge;
break;
}
}
}
}
}; };
useShortcuts({ useShortcuts({
meta_z: undo, meta_z: undo,
meta_y: redo, meta_y: redo,
Delete: () => { if(focusing.value !== undefined) { removeNode([focusing.value]) } } Delete: () => { if(focusing.value !== undefined) { remove([focusing.value]) } }
}); });
</script> </script>
@ -466,7 +492,7 @@ useShortcuts({
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4" @click="stopPropagation"> <div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4" @click="stopPropagation">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10"> <div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Zoom avant" side="right"> <Tooltip message="Zoom avant" side="right">
<div @click="zoomTween.update(clamp(zoom * 1.1, minZoom, 3), 250); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer"> <div @click="zoomTween.update(clamp(zoom * 1.1, minZoom, 3), TWEEN_DURATION); updateTransform()" 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" /> <Icon icon="radix-icons:plus" />
</div> </div>
</Tooltip> </Tooltip>
@ -481,7 +507,7 @@ useShortcuts({
</div> </div>
</Tooltip> </Tooltip>
<Tooltip message="Zoom arrière" side="right"> <Tooltip message="Zoom arrière" side="right">
<div @click="zoomTween.update(clamp(zoom / 1.1, minZoom, 3), 250); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer"> <div @click="zoomTween.update(clamp(zoom / 1.1, minZoom, 3), TWEEN_DURATION); updateTransform()" 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" /> <Icon icon="radix-icons:minus" />
</div> </div>
</Tooltip> </Tooltip>
@ -532,7 +558,7 @@ useShortcuts({
'transform-origin': 'center center', 'transform-origin': 'center center',
}" class="h-full"> }" 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 class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
<div v-if="focusing !== undefined && focusedNode !== undefined" class="absolute z-20 origin-bottom" :style="{transform: `translate(${focusedNode.x}px, ${focusedNode.y}px) translateY(-100%) translateY(-12px) translateX(-50%) translateX(${focusedNode.width / 2}px) scale(calc(1 / var(--tw-scale)))`}"> <div v-if="focusing !== undefined && focusing.type === 'node'" class="absolute z-20 origin-bottom" :style="{transform: `translate(${canvas.nodes!.find(e => e.id === focusing!.id)!.x}px, ${canvas.nodes!.find(e => e.id === focusing!.id)!.y}px) translateY(-100%) translateY(-12px) translateX(-50%) translateX(${canvas.nodes!.find(e => e.id === focusing!.id)!.width / 2}px) scale(calc(1 / var(--tw-scale)))`}">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row"> <div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row">
<PopoverRoot> <PopoverRoot>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -547,30 +573,30 @@ useShortcuts({
<PopoverPortal disabled> <PopoverPortal disabled>
<PopoverContent align="center" side="top" class="bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 m-2"> <PopoverContent align="center" side="top" class="bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 m-2">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row *:cursor-pointer"> <div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row *:cursor-pointer">
<div @click="editNodeProperty([focusing], 'color', undefined)" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="editNodeProperty([focusing.id], 'color', undefined)" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-40 dark:bg-dark-40 w-4 h-4 block"></span> <span class="bg-light-40 dark:bg-dark-40 w-4 h-4 block"></span>
</div> </div>
<div @click="editNodeProperty([focusing], 'color', { class: 'red' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="editNodeProperty([focusing.id], 'color', { class: 'red' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-red dark:bg-dark-red w-4 h-4 block"></span> <span class="bg-light-red dark:bg-dark-red w-4 h-4 block"></span>
</div> </div>
<div @click="editNodeProperty([focusing], 'color', { class: 'orange' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="editNodeProperty([focusing.id], 'color', { class: 'orange' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-orange dark:bg-dark-orange w-4 h-4 block"></span> <span class="bg-light-orange dark:bg-dark-orange w-4 h-4 block"></span>
</div> </div>
<div @click="editNodeProperty([focusing], 'color', { class: 'yellow' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="editNodeProperty([focusing.id], 'color', { class: 'yellow' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-yellow dark:bg-dark-yellow w-4 h-4 block"></span> <span class="bg-light-yellow dark:bg-dark-yellow w-4 h-4 block"></span>
</div> </div>
<div @click="editNodeProperty([focusing], 'color', { class: 'green' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="editNodeProperty([focusing.id], 'color', { class: 'green' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-green dark:bg-dark-green w-4 h-4 block"></span> <span class="bg-light-green dark:bg-dark-green w-4 h-4 block"></span>
</div> </div>
<div @click="editNodeProperty([focusing], 'color', { class: 'cyan' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="editNodeProperty([focusing.id], 'color', { class: 'cyan' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-cyan dark:bg-dark-cyan w-4 h-4 block"></span> <span class="bg-light-cyan dark:bg-dark-cyan w-4 h-4 block"></span>
</div> </div>
<div @click="editNodeProperty([focusing], 'color', { class: 'purple' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="editNodeProperty([focusing.id], 'color', { class: 'purple' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-purple dark:bg-dark-purple w-4 h-4 block"></span> <span class="bg-light-purple dark:bg-dark-purple w-4 h-4 block"></span>
</div> </div>
<label> <label>
<div @click="stopPropagation" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35"> <div @click="stopPropagation" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span style="background: conic-gradient(red, yellow, green, blue, purple, red)" class="w-4 h-4 block relative"></span><input @change="(e: Event) => editNodeProperty([focusing!], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" /> <span style="background: conic-gradient(red, yellow, green, blue, purple, red)" class="w-4 h-4 block relative"></span><input @change="(e: Event) => editNodeProperty([focusing!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
</div> </div>
</label> </label>
</div> </div>
@ -578,29 +604,18 @@ useShortcuts({
</PopoverPortal> </PopoverPortal>
</PopoverRoot> </PopoverRoot>
<Tooltip message="Supprimer" side="top"> <Tooltip message="Supprimer" side="top">
<div @click="removeNode([focusing])" class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer"> <div @click="remove([focusing])" class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:trash" class="text-light-red dark:text-dark-red w-6 h-6" /> <Icon icon="radix-icons:trash" class="text-light-red dark:text-dark-red w-6 h-6" />
</div> </div>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div> <div>
<CanvasNodeEditor v-for="(node, index) of canvas.nodes" :key="node.id" ref="nodes" :node="node" :index="index" :zoom="zoom" @select="selectNode" @edit="editNode" @move="(i, x, y) => moveNode([i], x, y)" @resize="(i, x, y, w, h) => resizeNode([i], x, y, w, h)" /> <CanvasNodeEditor v-for="node of canvas.nodes" :key="node.id" ref="nodes" :node="node" :zoom="zoom" @select="select" @edit="edit" @move="(i, x, y) => moveNode([i], x, y)" @resize="(i, x, y, w, h) => resizeNode([i], x, y, w, h)" @input="(id, text) => editNodeProperty([id], node.type === 'group' ? 'label' : 'text', text)" />
</div> </div>
<template v-for="edge of edges"> <div>
<div :key="edge.id" v-if="edge.label" class="absolute z-10" <CanvasEdgeEditor v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" @select="select" @edit="edit" />
: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> </div>
</template>
<svg class="absolute top-0 left-0 w-1 h-1 overflow-visible">
<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>
</div> </div>
</div> </div>

View File

@ -0,0 +1,67 @@
<template>
<div class="absolute overflow-visible h-px w-px">
<div v-if="edge.label" :style="{ transform: labelPos }" 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>
<input v-else-if="editing" @click="e => e.stopImmediatePropagation()" :style="{ transform: `${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" :v-model="edge.label" />
<svg ref="dom" class="absolute top-0 overflow-visible h-px w-px" @click="select" @dblclick="edit">
<g :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'), { 'outline-4': focusing }]" class="fill-none stroke-[4px]" :d="path!.path"></path>
<g :style="`transform: translate(${path!.to.x}px, ${path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[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>
</template>
<script setup lang="ts">
import { getPath, labelCenter, type Direction } from '#shared/canvas.util';
import type { Element } from '../CanvasEditor.vue';
import type { CanvasEdge, CanvasNode } from '~/types/canvas';
const rotation: Record<Direction, string> = {
top: "180",
bottom: "0",
left: "90",
right: "270"
};
const { edge, nodes } = defineProps<{
edge: CanvasEdge
nodes: CanvasNode[]
}>();
const emit = defineEmits<{
(e: 'select', id: Element): void,
(e: 'edit', id: Element): void,
(e: 'move', id: string, from: string, to: string): void,
}>();
const dom = useTemplateRef('dom');
const focusing = ref(false), editing = ref(false);
const from = computed(() => nodes!.find(f => f.id === edge.fromNode))
const to = computed(() => nodes!.find(f => f.id === edge.toNode));
const path = computed(() => getPath(from.value!, edge.fromSide, to.value!, edge.toSide));
const labelPos = computed(() => labelCenter(from.value!, edge.fromSide, to.value!, edge.toSide));
function select(e: Event) {
if(editing.value)
return;
focusing.value = true;
emit('select', { type: 'edge', id: edge.id });
}
function edit(e: Event) {
focusing.value = true;
editing.value = true;
e.stopImmediatePropagation();
emit('edit', { type: 'edge', id: edge.id });
}
function unselect() {
focusing.value = false;
editing.value = false;
}
defineExpose({ unselect, dom, id: edge.id });
</script>

View File

@ -34,7 +34,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Direction } from '~/shared/canvas.util'; import type { Direction } from '#shared/canvas.util';
import type { Element } from '../CanvasEditor.vue';
import FakeA from '../prose/FakeA.vue'; import FakeA from '../prose/FakeA.vue';
import type { CanvasNode } from '~/types/canvas'; import type { CanvasNode } from '~/types/canvas';
@ -44,21 +45,23 @@ const { node, zoom } = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select', id: string): void, (e: 'select', id: Element): void,
(e: 'edit', id: string): void, (e: 'edit', id: Element): void,
(e: 'move', id: string, x: number, y: number): void, (e: 'move', id: string, x: number, y: number): void,
(e: 'resize', id: string, x: number, y: number, w: number, h: number): void, (e: 'resize', id: string, x: number, y: number, w: number, h: number): void,
(e: 'input', id: string, text: string): void,
}>(); }>();
const dom = useTemplateRef('dom'); const dom = useTemplateRef('dom');
const focusing = ref(false), editing = ref(false); const focusing = ref(false), editing = ref(false);
let oldText = node.type === 'group' ? node.label : node.text;
function selectNode(e: Event) { function selectNode(e: Event) {
if(editing.value) if(editing.value)
return; return;
focusing.value = true; focusing.value = true;
emit('select', node.id); emit('select', { type: 'node', id: node.id });
dom.value?.addEventListener('mousedown', dragstart, { passive: true }); dom.value?.addEventListener('mousedown', dragstart, { passive: true });
} }
@ -66,10 +69,12 @@ function editNode(e: Event) {
focusing.value = true; focusing.value = true;
editing.value = true; editing.value = true;
oldText = node.type === 'group' ? node.label : node.text;
e.stopImmediatePropagation(); e.stopImmediatePropagation();
dom.value?.removeEventListener('mousedown', dragstart); dom.value?.removeEventListener('mousedown', dragstart);
emit('edit', node.id); 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, w: number, h: number) {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
@ -100,6 +105,18 @@ function dragEdge(e: Event, direction: Direction) {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
} }
function unselect() { function unselect() {
if(editing.value)
{
const text = node.type === 'group' ? node.label : node.text;
if(text !== oldText)
{
if(node.type === 'group')
node.label = oldText;
else
node.text = oldText;
emit('input', node.id, text);
}
}
focusing.value = false; focusing.value = false;
editing.value = false; editing.value = false;
@ -134,7 +151,7 @@ const dragstart = (e: MouseEvent) => {
window.addEventListener('mouseup', dragend, { passive: true }); window.addEventListener('mouseup', dragend, { passive: true });
}; };
defineExpose({ unselect, dom, ...node }); defineExpose({ unselect, dom, id: node.id });
const style = computed(() => { const style = computed(() => {
return node.color ? node.color?.class ? return node.color ? node.color?.class ?

View File

@ -1,54 +1,70 @@
import { clamp, lerp } from "~/shared/general.utils"; import { clamp } from "~/shared/general.utils";
export const linear = (progress: number): number => progress; export const linear = (progress: number): number => progress;
export const ease = (progress: number): number => -(Math.cos(Math.PI * progress) - 1) / 2; export const ease = (progress: number): number => -(Math.cos(Math.PI * progress) - 1) / 2;
export function useTween(ref: Ref<number>, animation: (progress: number) => number, then: () => void) export function useTween(ref: Ref<number>, animation: (progress: number) => number, then: () => void)
{ {
let initial = ref.value, current = ref.value, end = ref.value, progress = 0, time = 0, animationFrame: number, stop = true, last = 0; // State variables for animation
let initial = ref.value;
let current = ref.value;
let end = ref.value;
let animationFrame: number;
let stop = true;
let last = 0;
function loop(t: DOMHighResTimeStamp) // Velocity tracking with sign preservation
{ let velocity = 0;
const elapsed = t - last; const velocityDecay = 0.92; // Slightly faster decay for more responsive feel
progress = clamp(progress + elapsed, 0, time);
function loop(t: DOMHighResTimeStamp) {
// Cap the elapsed time to prevent jumps during lag or tab switches
const elapsed = Math.min(t - last, 32);
last = t; last = t;
const step = animation(clamp(progress / time, 0, 1)); // Update velocity considering the sign of the movement
current = lerp(initial, end, step); velocity = velocity * velocityDecay + (end - current) * (1 - velocityDecay);
// Apply velocity to current position
// Normalize by elapsed time to maintain consistent speed regardless of frame rate
current = clamp(current + velocity * (elapsed / 16), initial, end);
if(current === end)
stop = true;
// Trigger callback
then(); then();
if(progress < time && !stop) // Continue animation if not stopped
{ if (!stop) {
animationFrame = requestAnimationFrame(loop); animationFrame = requestAnimationFrame(loop);
} }
else
{
progress = 0;
stop = true;
}
} }
return { return {
stop: () => { stop: () => {
cancelAnimationFrame(animationFrame); cancelAnimationFrame(animationFrame);
stop = true; stop = true;
velocity = 0; // Reset velocity when stopping
}, },
update: (target: number, duration: number) => { update: (target: number, duration: number) => {
if (stop) {
// Only reset initial position when starting a new animation
initial = current; initial = current;
time = duration + progress; last = performance.now();
}
end = target; end = target;
ref.value = target; if (stop) {
if(stop)
{
stop = false; stop = false;
last = performance.now();
loop(performance.now()); loop(performance.now());
} }
}, },
refresh: () => { current = ref.value; }, refresh: () => {
current: () => { return current; }, current = ref.value;
velocity = 0; // Reset velocity on refresh
},
current: () => current,
}; };
} }

Binary file not shown.

Binary file not shown.