Add neighbor snapping. Add edge snapping. Change accent colors and logo colors, fix canvas history being transported when changing canvas.
This commit is contained in:
parent
e2c18ff406
commit
939b9cbd28
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { type Position } from '#shared/canvas.util';
|
||||
import { bezier, getBbox, getPath, opposite, posFromDir, rotation, type Box, type Direction, type Path, type Position } from '#shared/canvas.util';
|
||||
import type CanvasNodeEditor from './canvas/CanvasNodeEditor.vue';
|
||||
import type CanvasEdgeEditor from './canvas/CanvasEdgeEditor.vue';
|
||||
import { SnapFinder, type SnapHint } from '#shared/physics.util';
|
||||
import type { CanvasPreferences } from '~/types/general';
|
||||
export type Element = { type: 'node' | 'edge', id: string };
|
||||
|
||||
interface ActionMap {
|
||||
|
|
@ -60,29 +62,45 @@ import { clamp } from '#shared/general.util';
|
|||
import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas';
|
||||
|
||||
const canvas = defineModel<CanvasContent>({ required: true });
|
||||
const props = defineProps<{
|
||||
path: string,
|
||||
}>();
|
||||
|
||||
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
|
||||
const focusing = ref<Element>(), editing = ref<Element>();
|
||||
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
|
||||
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef'), toolbarRef = useTemplateRef('toolbarRef'), viewportSize = useElementSize(canvasRef);
|
||||
const nodes = useTemplateRef<NodeEditor[]>('nodes'), edges = useTemplateRef<EdgeEditor[]>('edges');
|
||||
const canvasSettings = useCookie<{
|
||||
snap: boolean,
|
||||
size: number
|
||||
}>('canvasPreference', { default: () => ({ snap: true, size: 32 }) });
|
||||
const snap = computed({
|
||||
get: () => canvasSettings.value.snap,
|
||||
set: (value: boolean) => canvasSettings.value = { ...canvasSettings.value, snap: value },
|
||||
}), gridSize = computed({
|
||||
get: () => canvasSettings.value.size,
|
||||
set: (value: number) => canvasSettings.value = { ...canvasSettings.value, size: value },
|
||||
const canvasSettings = useCookie<CanvasPreferences>('canvasPreference', { default: () => ({ gridSnap: true, neighborSnap: true }) });
|
||||
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 };
|
||||
});
|
||||
|
||||
const fakeEdge = ref<{ original?: string, from?: Position, fromSide?: Direction, to?: Position, toSide?: Direction, path?: Path, style?: { stroke: string, fill: string }, hex?: string, dragFrom?: string, snapped?: { node: string, side: Direction } }>({});
|
||||
|
||||
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);
|
||||
let snapFinder: SnapFinder;
|
||||
|
||||
const history = ref<HistoryEvent[]>([]);
|
||||
const historyPos = ref(-1);
|
||||
const historyCursor = computed(() => history.value.length > 0 && historyPos.value > -1 ? history.value[historyPos.value] : undefined);
|
||||
|
||||
watch(props, () => {
|
||||
snapFinder = new SnapFinder(hints, viewport, { gridSize: 512, preferences: canvasSettings.value, threshold: 16, cellSize: 64 })
|
||||
canvas.value.nodes?.forEach((e) => snapFinder.update(e));
|
||||
focusing.value = undefined;
|
||||
editing.value = undefined;
|
||||
history.value = [];
|
||||
historyPos.value = -1;
|
||||
fakeEdge.value = {};
|
||||
}, { immediate: true });
|
||||
|
||||
watch(canvas, () => {
|
||||
updateToolbarTransform();
|
||||
}, { immediate: true, deep: true, });
|
||||
|
||||
const reset = (_: MouseEvent) => {
|
||||
zoom.value = minZoom.value;
|
||||
|
||||
|
|
@ -212,6 +230,28 @@ function updateTransform()
|
|||
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
|
||||
}
|
||||
}
|
||||
function updateToolbarTransform()
|
||||
{
|
||||
const offsetY = -12;
|
||||
if(toolbarRef.value)
|
||||
{
|
||||
if(!focusing.value)
|
||||
{
|
||||
toolbarRef.value.style.transform = '';
|
||||
}
|
||||
else if(focusing.value.type === 'node')
|
||||
{
|
||||
const node = canvas.value.nodes!.find(e => e.id === focusing.value!.id)!;
|
||||
toolbarRef.value.style.transform = `translate(${node.x}px, ${node.y}px) translateY(-100%) translateY(${offsetY}px) translateX(-50%) translateX(${node.width / 2}px) scale(calc(1 / var(--tw-scale)))`;
|
||||
}
|
||||
else
|
||||
{
|
||||
const path = edges.value!.find(e => e.id === focusing.value!.id)!.path;
|
||||
const x = path.from.x + (path.to.x - path.from.x) / 2, y = path.from.y;
|
||||
toolbarRef.value.style.transform = `translate(${x}px, ${y}px) translateY(-100%) translateY(${offsetY}px) translateX(-50%) scale(calc(1 / var(--tw-scale)))`;
|
||||
}
|
||||
}
|
||||
}
|
||||
function moveNode(ids: string[], deltax: number, deltay: number)
|
||||
{
|
||||
if(ids.length === 0)
|
||||
|
|
@ -221,6 +261,7 @@ function moveNode(ids: string[], deltax: number, deltay: number)
|
|||
for(const id of ids)
|
||||
{
|
||||
const node = canvas.value.nodes!.find(e => e.id === id)!;
|
||||
snapFinder.update(node);
|
||||
|
||||
actions.push({ element: { type: 'node', id }, from: { ...node, x: node.x - deltax, y: node.y - deltay }, to: { ...node } });
|
||||
}
|
||||
|
|
@ -236,6 +277,7 @@ function resizeNode(ids: string[], deltax: number, deltay: number, deltaw: numbe
|
|||
for(const id of ids)
|
||||
{
|
||||
const node = canvas.value.nodes!.find(e => e.id === id)!;
|
||||
snapFinder.update(node);
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
|
@ -253,6 +295,7 @@ function select(element: Element)
|
|||
|
||||
focused.value?.dom?.addEventListener('click', stopPropagation, { passive: true });
|
||||
canvasRef.value?.addEventListener('click', unselect, { once: true });
|
||||
updateToolbarTransform();
|
||||
}
|
||||
function edit(element: Element)
|
||||
{
|
||||
|
|
@ -275,6 +318,7 @@ function createNode(e: MouseEvent)
|
|||
else
|
||||
canvas.value.nodes.push(node);
|
||||
|
||||
snapFinder.add(node);
|
||||
addAction('create', [{ element: { type: 'node', id: node.id }, from: undefined, to: node }]);
|
||||
}
|
||||
function remove(elements: Element[])
|
||||
|
|
@ -299,7 +343,10 @@ function remove(elements: Element[])
|
|||
}
|
||||
|
||||
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 });
|
||||
const node = c.nodes!.splice(index, 1)[0];
|
||||
|
||||
snapFinder.remove(node);
|
||||
actions.push({ element: { type: 'node', id: element.id }, from: node, to: undefined });
|
||||
}
|
||||
else if(element.type === 'edge' && !actions.find(e => e.element.type === 'edge' && e.element.id === element.id))
|
||||
{
|
||||
|
|
@ -312,6 +359,61 @@ function remove(elements: Element[])
|
|||
|
||||
addAction('remove', actions);
|
||||
}
|
||||
function dragEdgeTo(e: MouseEvent): void
|
||||
{
|
||||
(fakeEdge.value.to as Position).x += e.movementX / zoom.value;
|
||||
(fakeEdge.value.to as Position).y += e.movementY / zoom.value;
|
||||
|
||||
const result = snapFinder.findEdgeSnapPosition(fakeEdge.value.dragFrom!, fakeEdge.value.to!.x, fakeEdge.value.to!.y);
|
||||
|
||||
fakeEdge.value.snapped = result ? { node: result.node, side: result.direction } : undefined;
|
||||
fakeEdge.value.path = bezier((fakeEdge.value.from as Position), fakeEdge.value.fromSide!, result ?? (fakeEdge.value.to as Position), result?.direction ?? fakeEdge.value.toSide!);
|
||||
}
|
||||
function dragEndEdgeTo(e: MouseEvent): void
|
||||
{
|
||||
window.removeEventListener('mousemove', dragEdgeTo);
|
||||
window.removeEventListener('mouseup', dragEndEdgeTo);
|
||||
|
||||
if(fakeEdge.value.snapped)
|
||||
{
|
||||
const node = canvas.value.nodes!.find(e => e.id === fakeEdge.value.dragFrom)!;
|
||||
const edge: CanvasEdge = { fromNode: fakeEdge.value.dragFrom!, fromSide: fakeEdge.value.fromSide!, toNode: fakeEdge.value.snapped.node, toSide: fakeEdge.value.snapped.side, id: getID(16), color: node.color };
|
||||
canvas.value.edges?.push(edge);
|
||||
|
||||
addAction('create', [{ from: undefined, to: edge, element: { id: edge.id, type: 'edge' } }]);
|
||||
}
|
||||
|
||||
fakeEdge.value = {};
|
||||
}
|
||||
function dragStartEdgeTo(id: string, e: MouseEvent, direction: Direction): void
|
||||
{
|
||||
const node = canvas.value.nodes!.find(e => e.id === id)!;
|
||||
fakeEdgeFromNode(node, direction);
|
||||
|
||||
window.addEventListener('mousemove', dragEdgeTo, { passive: true });
|
||||
window.addEventListener('mouseup', dragEndEdgeTo, { passive: true });
|
||||
}
|
||||
function fakeEdgeFromNode(node: CanvasNode, direction: Direction): void
|
||||
{
|
||||
const pos = posFromDir(getBbox(node), direction);
|
||||
|
||||
fakeEdge.value.original = undefined;
|
||||
fakeEdge.value.dragFrom = node.id;
|
||||
|
||||
fakeEdge.value.from = { ... pos };
|
||||
fakeEdge.value.fromSide = direction;
|
||||
|
||||
fakeEdge.value.to = { ... pos };
|
||||
fakeEdge.value.toSide = opposite[direction];
|
||||
|
||||
fakeEdge.value.path = bezier(pos, fakeEdge.value.fromSide!, pos, fakeEdge.value.toSide!);
|
||||
fakeEdge.value.hex = node.color?.hex;
|
||||
|
||||
fakeEdge.value.style = node?.color ? node.color?.class ?
|
||||
{ fill: `fill-light-${node.color?.class} dark:fill-dark-${node.color?.class}`, stroke: `stroke-light-${node.color?.class} dark:stroke-dark-${node.color?.class}` } :
|
||||
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
|
||||
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` };
|
||||
}
|
||||
function editNodeProperty<T extends keyof CanvasNode>(ids: string[], property: T, value: CanvasNode[T])
|
||||
{
|
||||
if(ids.length === 0)
|
||||
|
|
@ -346,11 +448,11 @@ function editEdgeProperty<T extends keyof CanvasEdge>(ids: string[], property: T
|
|||
}
|
||||
|
||||
const unselect = () => {
|
||||
if(focusing.value) console.log("Unselecting %s (%s)", focusing.value.id, focusing.value.type);
|
||||
if(focusing.value !== undefined)
|
||||
{
|
||||
focused.value?.dom?.removeEventListener('click', stopPropagation);
|
||||
focused.value?.unselect();
|
||||
updateToolbarTransform();
|
||||
}
|
||||
focusing.value = undefined;
|
||||
|
||||
|
|
@ -377,13 +479,14 @@ const undo = () => {
|
|||
{
|
||||
const a = action as HistoryAction<'create'>;
|
||||
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||
canvas.value.nodes!.splice(index, 1);
|
||||
snapFinder.remove(canvas.value.nodes!.splice(index, 1)[0]);
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
{
|
||||
const a = action as HistoryAction<'remove'>;
|
||||
canvas.value.nodes!.push(a.from as CanvasNode);
|
||||
snapFinder.add(a.from as CanvasNode);
|
||||
break;
|
||||
}
|
||||
case 'property':
|
||||
|
|
@ -391,6 +494,7 @@ const undo = () => {
|
|||
const a = action as HistoryAction<'property'>;
|
||||
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||
canvas.value.nodes![index] = a.from as CanvasNode;
|
||||
snapFinder.update(a.from as CanvasNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -447,13 +551,14 @@ const redo = () => {
|
|||
{
|
||||
const a = action as HistoryAction<'create'>;
|
||||
canvas.value.nodes!.push(a.to as CanvasNode);
|
||||
snapFinder.add(a.to as CanvasNode);
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
{
|
||||
const a = action as HistoryAction<'remove'>;
|
||||
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||
canvas.value.nodes!.splice(index, 1);
|
||||
snapFinder.remove(canvas.value.nodes!.splice(index, 1)[0]);
|
||||
break;
|
||||
}
|
||||
case 'property':
|
||||
|
|
@ -461,6 +566,7 @@ const redo = () => {
|
|||
const a = action as HistoryAction<'property'>;
|
||||
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||
canvas.value.nodes![index] = a.to as CanvasNode;
|
||||
snapFinder.update(a.to as CanvasNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -572,64 +678,134 @@ useShortcuts({
|
|||
'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 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">
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger asChild>
|
||||
<div @click="stopPropagation">
|
||||
<Tooltip message="Couleur" side="top">
|
||||
<div 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="ph:palette" class="w-6 h-6" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<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">
|
||||
<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.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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<label>
|
||||
<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!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
|
||||
<div class="absolute z-20 origin-bottom" ref="toolbarRef">
|
||||
<template v-if="focusing">
|
||||
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row" v-if="focusing.type === 'node'">
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger asChild>
|
||||
<div @click="stopPropagation">
|
||||
<Tooltip message="Couleur" side="top">
|
||||
<div 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="ph:palette" class="w-6 h-6" />
|
||||
</div>
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
<Tooltip message="Supprimer" side="top">
|
||||
<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" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<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">
|
||||
<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.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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<label>
|
||||
<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!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
<Tooltip message="Supprimer" side="top">
|
||||
<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" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row" v-else>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger asChild>
|
||||
<div @click="stopPropagation">
|
||||
<Tooltip message="Couleur" side="top">
|
||||
<div 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="ph:palette" class="w-6 h-6" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<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">
|
||||
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row *:cursor-pointer">
|
||||
<div @click="editEdgeProperty([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>
|
||||
</div>
|
||||
<div @click="editEdgeProperty([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>
|
||||
</div>
|
||||
<div @click="editEdgeProperty([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>
|
||||
</div>
|
||||
<div @click="editEdgeProperty([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>
|
||||
</div>
|
||||
<div @click="editEdgeProperty([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>
|
||||
</div>
|
||||
<div @click="editEdgeProperty([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>
|
||||
</div>
|
||||
<div @click="editEdgeProperty([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>
|
||||
</div>
|
||||
<label>
|
||||
<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) => editEdgeProperty([focusing!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
<Tooltip message="Supprimer" side="top">
|
||||
<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" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<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)" :snapping="snap" :grid="gridSize" />
|
||||
<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)" :snap="snapFinder.findNodeSnapPosition.bind(snapFinder)" @edge="dragStartEdgeTo" />
|
||||
</div>
|
||||
<div>
|
||||
<CanvasEdgeEditor v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" @select="select" @edit="edit" @input="(id, text) => editEdgeProperty([id], 'label', text)" />
|
||||
<div v-if="fakeEdge.path" class="absolute overflow-visible">
|
||||
<svg class="absolute top-0 overflow-visible h-px w-px">
|
||||
<g :style="{'--canvas-color': fakeEdge.hex}" class="z-0">
|
||||
<g :style="`transform: translate(${fakeEdge.path!.to.x}px, ${fakeEdge.path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[fakeEdge.path!.side]}deg);`">
|
||||
<polygon :class="fakeEdge.style?.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||
</g>
|
||||
<path :style="`stroke-width: calc(3px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="fakeEdge.style?.stroke" class="fill-none stroke-[4px]" :d="fakeEdge.path.path"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="absolute overflow-visible top-0 overflow-visible h-px w-px fill-accent-purple stroke-accent-purple stroke-1 z-50">
|
||||
<g v-for="hint of hints">
|
||||
<circle :cx="hint.start.x" :cy="hint.start.y" r="3" />
|
||||
<circle v-if="hint.end" :cx="hint.end.x" :cy="hint.end.y" r="3" />
|
||||
<line v-if="hint.end" :x1="hint.start.x" :x2="hint.end.x" :y1="hint.start.y" :y2="hint.end.y"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ const editor = useTemplateRef('editor');
|
|||
const view = ref<EditorView>();
|
||||
const state = ref<EditorState>();
|
||||
|
||||
const { placeholder } = defineProps<{
|
||||
const { placeholder, autofocus = false } = defineProps<{
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
|
||||
|
|
@ -59,6 +60,11 @@ onMounted(() => {
|
|||
state: state.value,
|
||||
parent: editor.value,
|
||||
});
|
||||
|
||||
if(autofocus)
|
||||
{
|
||||
view.value.focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,15 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Direction } from '#shared/canvas.util';
|
||||
const rotation: Record<Direction, string> = {
|
||||
top: "180",
|
||||
bottom: "0",
|
||||
left: "90",
|
||||
right: "270"
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.fill-colored
|
||||
{
|
||||
--tw-bg-opacity: 1;
|
||||
fill: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { getPath, labelCenter } from '#shared/canvas.util';
|
||||
import { getPath, labelCenter, rotation } from '#shared/canvas.util';
|
||||
import type { CanvasEdge, CanvasNode } from '~/types/canvas';
|
||||
|
||||
const { edge, nodes } = defineProps<{
|
||||
|
|
|
|||
|
|
@ -4,28 +4,28 @@
|
|||
<div v-else-if="edge.label" :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" @click.left="select" @dblclick.left="edit">{{ edge.label }}</div>
|
||||
<svg ref="dom" class="absolute top-0 overflow-visible h-px w-px">
|
||||
<g :style="{'--canvas-color': edge.color?.hex}" class="z-0">
|
||||
<g :style="`transform: translate(${path!.to.x}px, ${path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path!.side]}deg);`">
|
||||
<g :style="`transform: translate(${path.to.x}px, ${path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path.side]}deg);`">
|
||||
<polygon :class="style.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||
</g>
|
||||
<path :style="`stroke-width: calc(${focusing ? 6 : 3}px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="style.stroke" class="fill-none stroke-[4px]" :d="path!.path"></path>
|
||||
<path style="stroke-width: calc(22px * var(--zoom-multiplier));" class="fill-none transition-opacity z-30 opacity-0 hover:opacity-25" :class="[style.stroke, { 'opacity-25': focusing }]" :d="path!.path" @click="select" @dblclick="edit"></path>
|
||||
<path :style="`stroke-width: calc(${focusing ? 6 : 3}px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="style.stroke" class="transition-[stroke-width] fill-none stroke-[4px]" :d="path.path"></path>
|
||||
<path style="stroke-width: calc(22px * var(--zoom-multiplier));" class="fill-none transition-opacity z-30 opacity-0 hover:opacity-25" :class="[style.stroke, { 'opacity-25': focusing }]" :d="path.path" @click="select" @dblclick="edit"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.fill-colored
|
||||
{
|
||||
--tw-bg-opacity: 1;
|
||||
fill: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { getPath, labelCenter, type Direction } from '#shared/canvas.util';
|
||||
import { getPath, labelCenter, rotation } 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[]
|
||||
|
|
@ -43,7 +43,7 @@ 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 path = computed(() => getPath(from.value!, edge.fromSide, to.value!, edge.toSide)!);
|
||||
const labelPos = computed(() => labelCenter(from.value!, edge.fromSide, to.value!, edge.toSide));
|
||||
|
||||
let oldText = edge.label;
|
||||
|
|
@ -52,16 +52,12 @@ function select(e: Event) {
|
|||
if(editing.value)
|
||||
return;
|
||||
|
||||
console.log("Selecting %s (edge)", edge.id);
|
||||
|
||||
focusing.value = true;
|
||||
emit('select', { type: 'edge', id: edge.id });
|
||||
}
|
||||
function edit(e: Event) {
|
||||
oldText = edge.label;
|
||||
|
||||
console.log("Editing %s (edge)", edge.id);
|
||||
|
||||
focusing.value = true;
|
||||
editing.value = true;
|
||||
|
||||
|
|
@ -84,7 +80,7 @@ function unselect() {
|
|||
editing.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ unselect, dom, id: edge.id });
|
||||
defineExpose({ unselect, dom, id: edge.id, path });
|
||||
|
||||
const style = computed(() => {
|
||||
return edge.color ? edge.color?.class ?
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bg-colored
|
||||
{
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import type { CanvasNode } from '~/types/canvas';
|
||||
|
||||
|
|
|
|||
|
|
@ -26,24 +26,30 @@
|
|||
</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" >
|
||||
<Editor v-model="node.text" />
|
||||
<Editor v-model="node.text" autofocus />
|
||||
</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()" 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>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bg-colored
|
||||
{
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { gridSnap, type Direction } from '#shared/canvas.util';
|
||||
import type { Box, Direction } from '#shared/canvas.util';
|
||||
import type { Element } from '../CanvasEditor.vue';
|
||||
import FakeA from '../prose/FakeA.vue';
|
||||
import type { CanvasNode } from '~/types/canvas';
|
||||
|
||||
const { node, zoom, snapping, grid } = defineProps<{
|
||||
const { node, zoom, snap } = defineProps<{
|
||||
node: CanvasNode
|
||||
zoom: number
|
||||
snapping: boolean
|
||||
grid: number
|
||||
zoom: number,
|
||||
snap: (activeNode: CanvasNode, resizeHandle?: Box) => Partial<Box>,
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -52,6 +58,7 @@ const emit = defineEmits<{
|
|||
(e: 'move', id: string, x: number, y: number): void,
|
||||
(e: 'resize', id: string, x: number, y: number, w: number, h: number): void,
|
||||
(e: 'input', id: string, text: string): void,
|
||||
(e: 'edge', id: string, _e: MouseEvent, side: Direction): void,
|
||||
}>();
|
||||
|
||||
const dom = useTemplateRef('dom');
|
||||
|
|
@ -87,19 +94,22 @@ function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
|
|||
if(e.button !== 0)
|
||||
return;
|
||||
|
||||
realx += (e.movementX / zoom) * x;
|
||||
realy += (e.movementY / zoom) * y;
|
||||
realw += (e.movementX / zoom) * w;
|
||||
realh += (e.movementY / zoom) * h;
|
||||
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);
|
||||
|
||||
node.x = snapping ? gridSnap(realx, grid) : realx;
|
||||
node.y = snapping ? gridSnap(realy, grid) : realy;
|
||||
node.width = snapping ? gridSnap(realw, grid) : realw;
|
||||
node.height = snapping ? gridSnap(realh, grid) : realh;
|
||||
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy, width: realw, height: realh }, { x, y, w, h });
|
||||
|
||||
node.x = result?.x ?? realx;
|
||||
node.y = result?.y ?? realy;
|
||||
node.width = result?.w ?? realw;
|
||||
node.height = result?.h ?? realh;
|
||||
};
|
||||
const resizeend = (e: MouseEvent) => {
|
||||
if(e.button !== 0)
|
||||
return;
|
||||
|
||||
emit('resize', node.id, node.x - startx, node.y - starty, node.width - startw, node.height - starth);
|
||||
|
||||
window.removeEventListener('mousemove', resizemove);
|
||||
|
|
@ -111,6 +121,8 @@ function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
|
|||
}
|
||||
function dragEdge(e: Event, direction: Direction) {
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
emit('edge', node.id, e, direction)
|
||||
}
|
||||
function unselect() {
|
||||
if(editing.value)
|
||||
|
|
@ -142,8 +154,10 @@ const dragmove = (e: MouseEvent) => {
|
|||
realx += e.movementX / zoom;
|
||||
realy += e.movementY / zoom;
|
||||
|
||||
node.x = snapping ? gridSnap(realx, grid) : realx;
|
||||
node.y = snapping ? gridSnap(realy, grid) : realy;
|
||||
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy });
|
||||
|
||||
node.x = result?.x ?? realx;
|
||||
node.y = result?.y ?? realy;
|
||||
};
|
||||
const dragend = (e: MouseEvent) => {
|
||||
if(e.button !== 0)
|
||||
|
|
@ -152,8 +166,7 @@ const dragend = (e: MouseEvent) => {
|
|||
window.removeEventListener('mousemove', dragmove);
|
||||
window.removeEventListener('mouseup', dragend);
|
||||
|
||||
if(node.x - lastx !== 0 && node.y - lasty !== 0)
|
||||
emit('move', node.id, node.x - lastx, node.y - lasty);
|
||||
emit('move', node.id, node.x - lastx, node.y - lasty);
|
||||
};
|
||||
const dragstart = (e: MouseEvent) => {
|
||||
if(e.button !== 0)
|
||||
|
|
|
|||
BIN
db.sqlite-shm
BIN
db.sqlite-shm
Binary file not shown.
BIN
db.sqlite-wal
BIN
db.sqlite-wal
Binary file not shown.
|
|
@ -103,8 +103,8 @@ export default defineNuxtConfig({
|
|||
100: '#dadada',
|
||||
},
|
||||
accent: {
|
||||
purple: '#8a5cf5',
|
||||
blue: '#53aaf5',
|
||||
purple: '#43A047',
|
||||
blue: '#26C6DA',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@
|
|||
</template>
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'canvas'">
|
||||
<CanvasEditor v-if="selected.content" :modelValue="selected.content" />
|
||||
<CanvasEditor v-if="selected.content" :modelValue="selected.content" :path="getPath(selected)" />
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'map'">
|
||||
<span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de carte en cours de développement</ProseH3></span>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@ export type Path = {
|
|||
side: Direction;
|
||||
}
|
||||
|
||||
export const rotation: Record<Direction, string> = {
|
||||
top: "180",
|
||||
bottom: "0",
|
||||
left: "90",
|
||||
right: "270"
|
||||
};
|
||||
export const opposite: Record<Direction, Direction> = {
|
||||
top: "bottom",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
right: "left"
|
||||
}
|
||||
|
||||
export function edgePos(side: Direction, pos: Position, offset: number): Position {
|
||||
switch (side) {
|
||||
case "left":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
import type { CanvasContent, CanvasNode } from "~/types/canvas";
|
||||
import type { CanvasPreferences } from "~/types/general";
|
||||
import type { Position, Box, Direction } from "./canvas.util";
|
||||
|
||||
interface SnapPoint {
|
||||
pos: Position;
|
||||
type: TYPE;
|
||||
side?: Direction;
|
||||
}
|
||||
|
||||
export interface SnapHint {
|
||||
start: Position,
|
||||
end?: Position,
|
||||
}
|
||||
|
||||
interface SnapConfig {
|
||||
preferences: CanvasPreferences;
|
||||
threshold: number;
|
||||
cellSize: number;
|
||||
gridSize: number;
|
||||
}
|
||||
|
||||
const enum TYPE {
|
||||
CENTER,
|
||||
CORNER,
|
||||
EDGE,
|
||||
}
|
||||
|
||||
class SpatialGrid {
|
||||
private cells: Map<number, Map<number, Set<string>>> = new Map();
|
||||
private cellSize: number;
|
||||
|
||||
private minx: number = Infinity;
|
||||
private miny: number = Infinity;
|
||||
private maxx: number = -Infinity;
|
||||
private maxy: number = -Infinity;
|
||||
|
||||
private cacheSet: Set<string> = new Set<string>();
|
||||
|
||||
constructor(cellSize: number) {
|
||||
this.cellSize = cellSize;
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
const endY = Math.ceil((node.y + node.height) / this.cellSize);
|
||||
|
||||
this.updateBorders(startX, startY, endX, endY);
|
||||
|
||||
for (let i = startX; i <= endX; i++) {
|
||||
let gridX = this.cells.get(i);
|
||||
if (!gridX) {
|
||||
gridX = new Map<number, Set<string>>();
|
||||
this.cells.set(i, gridX);
|
||||
}
|
||||
|
||||
for (let j = startY; j <= endY; j++) {
|
||||
let gridY = gridX.get(j);
|
||||
if (!gridY) {
|
||||
gridY = new Set<string>();
|
||||
gridX.set(j, gridY);
|
||||
}
|
||||
gridY.add(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(node: CanvasNode): 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);
|
||||
const endY = Math.ceil((node.y + node.height) / this.cellSize);
|
||||
|
||||
for (let i = startX; i <= endX; i++) {
|
||||
const gridX = this.cells.get(i);
|
||||
if (gridX) {
|
||||
for (let j = startY; j <= endY; j++) {
|
||||
gridX.get(j)?.delete(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetch(x: number, y: number): Set<string> | undefined {
|
||||
return this.query(x, y, x, y);
|
||||
}
|
||||
|
||||
query(x1: number, y1: number, x2: number, y2: number): Set<string> {
|
||||
this.cacheSet.clear();
|
||||
|
||||
const startX = Math.floor(x1 / this.cellSize);
|
||||
const startY = Math.floor(y1 / this.cellSize);
|
||||
const endX = Math.ceil(x2 / this.cellSize);
|
||||
const endY = Math.ceil(y2 / this.cellSize);
|
||||
|
||||
for (let dx = startX; dx <= endX; dx++) {
|
||||
const gridX = this.cells.get(dx);
|
||||
if (gridX) {
|
||||
for (let dy = startY; dy <= endY; dy++) {
|
||||
const cellNodes = gridX.get(dy);
|
||||
if (cellNodes) {
|
||||
cellNodes.forEach(neighbor => this.cacheSet.add(neighbor));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.cacheSet;
|
||||
}
|
||||
|
||||
getViewportNeighbors(node: CanvasNode, viewport?: Box): Set<string> {
|
||||
this.cacheSet.clear();
|
||||
|
||||
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);
|
||||
const endY = Math.ceil((node.y + node.height) / this.cellSize);
|
||||
|
||||
const minX = viewport ? Math.max(this.minx, Math.floor(viewport.x / this.cellSize)) : this.minx;
|
||||
const minY = viewport ? Math.max(this.miny, Math.floor(viewport.y / this.cellSize)) : this.miny;
|
||||
const maxX = viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.w) / this.cellSize)) : this.maxx;
|
||||
const maxY = viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.h) / this.cellSize)) : this.maxy;
|
||||
|
||||
for (let dx = minX; dx <= maxX; dx++) {
|
||||
const gridX = this.cells.get(dx);
|
||||
if (gridX) {
|
||||
for (let dy = startY; dy <= endY; dy++) {
|
||||
const cellNodes = gridX.get(dy);
|
||||
if (cellNodes) {
|
||||
cellNodes.forEach(neighbor => {
|
||||
if (neighbor !== node.id) this.cacheSet.add(neighbor);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let dx = startX; dx <= endX; dx++) {
|
||||
const gridX = this.cells.get(dx);
|
||||
if (gridX) {
|
||||
for (let dy = minY; dy <= maxY; dy++) {
|
||||
const cellNodes = gridX.get(dy);
|
||||
if (cellNodes) {
|
||||
cellNodes.forEach(neighbor => {
|
||||
if (neighbor !== node.id) this.cacheSet.add(neighbor);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.cacheSet;
|
||||
}
|
||||
}
|
||||
|
||||
class SnapPointCache {
|
||||
private cache: Map<string, SnapPoint[]>;
|
||||
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
getSnapPoints(node: string): SnapPoint[] | undefined {
|
||||
return this.cache.get(node);
|
||||
}
|
||||
|
||||
private calculateSnapPoints(node: CanvasNode): SnapPoint[] {
|
||||
const centerX = node.x + node.width / 2;
|
||||
const centerY = node.y + node.height / 2;
|
||||
return [
|
||||
{ pos: { x: centerX, y: centerY }, type: TYPE.CENTER },
|
||||
{ pos: { x: node.x, y: node.y }, type: TYPE.CORNER },
|
||||
{ pos: { x: node.x + node.width, y: node.y }, type: TYPE.CORNER },
|
||||
{ pos: { x: node.x, y: node.y + node.height }, type: TYPE.CORNER },
|
||||
{ pos: { x: node.x + node.width, y: node.y + node.height }, type: TYPE.CORNER },
|
||||
{ pos: { x: centerX, y: node.y }, type: TYPE.EDGE, side: 'top' },
|
||||
{ pos: { x: node.x, y: centerY }, type: TYPE.EDGE, side: 'left' },
|
||||
{ pos: { x: centerX, y: node.y + node.height }, type: TYPE.EDGE, side: 'bottom' },
|
||||
{ pos: { x: node.x + node.width, y: centerY }, type: TYPE.EDGE, side: 'right' },
|
||||
];
|
||||
}
|
||||
|
||||
insert(node: CanvasNode): void {
|
||||
if (!this.cache.has(node.id)) {
|
||||
this.cache.set(node.id, this.calculateSnapPoints(node));
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(node: CanvasNode): void {
|
||||
this.cache.delete(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapFinder {
|
||||
private spatialGrid: SpatialGrid;
|
||||
private snapPointCache: SnapPointCache;
|
||||
private config: SnapConfig;
|
||||
|
||||
hints: Ref<SnapHint[]>;
|
||||
viewport: Ref<Box>;
|
||||
|
||||
constructor(hints: Ref<SnapHint[]>, viewport: Ref<Box>, config: SnapConfig) {
|
||||
this.spatialGrid = new SpatialGrid(config.cellSize);
|
||||
this.snapPointCache = new SnapPointCache();
|
||||
this.config = config;
|
||||
|
||||
this.hints = hints;
|
||||
this.viewport = viewport;
|
||||
}
|
||||
|
||||
add(node: CanvasNode): void {
|
||||
this.spatialGrid.insert(node);
|
||||
this.snapPointCache.insert(node);
|
||||
this.hints.value.length = 0;
|
||||
}
|
||||
|
||||
remove(node: CanvasNode): void {
|
||||
this.spatialGrid.remove(node);
|
||||
this.snapPointCache.invalidate(node);
|
||||
this.hints.value.length = 0;
|
||||
}
|
||||
|
||||
update(node: CanvasNode): void {
|
||||
this.remove(node);
|
||||
this.add(node);
|
||||
}
|
||||
|
||||
findEdgeSnapPosition(node: string, x: number, y: number): { x: number, y: number, node: string, direction: Direction } | undefined {
|
||||
const near = [...this.spatialGrid.fetch(x, y)?.values().filter(e => e !== node).flatMap(e => this.snapPointCache.getSnapPoints(e)?.map(_e => ({ ..._e, node: e })) ?? []) ?? []].filter(e => e.type === TYPE.EDGE);
|
||||
let nearestDistance = this.config.threshold, nearest = undefined;
|
||||
|
||||
for (const point of near) {
|
||||
const distance = Math.hypot(point.pos.x - x, point.pos.y - y);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearest = { ...point.pos, node: point.node, direction: point.side! };
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
findNodeSnapPosition(node: CanvasNode, resizeHandle?: Box): Partial<Box> {
|
||||
const result: Partial<Box> = {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
w: undefined,
|
||||
h: undefined,
|
||||
};
|
||||
|
||||
this.hints.value.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 bestSnap = this.findBestSnap(this.snapPointCache.getSnapPoints(node.id)!, neighbors, this.config.threshold, resizeHandle);
|
||||
|
||||
return this.applySnap(node, bestSnap.x, bestSnap.y, resizeHandle);
|
||||
}
|
||||
|
||||
private findBestSnap(activePoints: SnapPoint[], otherPoints: SnapPoint[], threshold: number, resizeHandle?: Box): Partial<Position> {
|
||||
let bestSnap: Partial<Position> = {};
|
||||
let bestDiffX = threshold, bestDiffY = threshold;
|
||||
let xHints: SnapHint[] = [], yHints: SnapHint[] = [];
|
||||
|
||||
for (const activePoint of activePoints) {
|
||||
if (activePoint.type === TYPE.EDGE) continue;
|
||||
if (!!resizeHandle && activePoint.type !== TYPE.CORNER) continue;
|
||||
|
||||
for (const otherPoint of otherPoints) {
|
||||
if (otherPoint.type === TYPE.EDGE) continue;
|
||||
if (!!resizeHandle && otherPoint.type !== TYPE.CORNER) continue;
|
||||
|
||||
const diffX = Math.abs(otherPoint.pos.x - activePoint.pos.x);
|
||||
const diffY = Math.abs(otherPoint.pos.y - activePoint.pos.y);
|
||||
|
||||
if (diffX < bestDiffX) {
|
||||
bestDiffX = diffX;
|
||||
bestSnap.x = otherPoint.pos.x - activePoint.pos.x;
|
||||
xHints = [{ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }];
|
||||
} else if(diffX === bestDiffX) {
|
||||
xHints.push({ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } });
|
||||
}
|
||||
|
||||
if (diffY < bestDiffY) {
|
||||
bestDiffY = diffY;
|
||||
bestSnap.y = otherPoint.pos.y - activePoint.pos.y;
|
||||
yHints = [{ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }];
|
||||
} else if(diffY === bestDiffY) {
|
||||
yHints.push({ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(bestSnap.x && bestSnap.y)
|
||||
{
|
||||
xHints.forEach(e => e.start.y += bestSnap.y!);
|
||||
yHints.forEach(e => e.start.x += bestSnap.x!);
|
||||
}
|
||||
|
||||
this.hints.value = [...xHints, ...yHints];
|
||||
|
||||
return bestSnap;
|
||||
}
|
||||
|
||||
private applySnap(node: CanvasNode, offsetx?: number, offsety?: number, resizeHandle?: Box): Partial<Box> {
|
||||
const result: Partial<Box> = { x: undefined, y: undefined, w: undefined, h: undefined };
|
||||
|
||||
if (resizeHandle) {
|
||||
if (offsetx) result.x = node.x + offsetx * resizeHandle.x;
|
||||
if (offsetx) result.w = node.width + offsetx * resizeHandle.w;
|
||||
if (offsety) result.y = node.y + offsety * resizeHandle.y;
|
||||
if (offsety) result.h = node.height - offsety * resizeHandle.h;
|
||||
} else {
|
||||
if (offsetx) result.x = node.x + offsetx;
|
||||
if (offsety) result.y = node.y + offsety;
|
||||
}
|
||||
|
||||
//console.log(result, offsetx, offsety);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
10
todo.md
10
todo.md
|
|
@ -1,9 +1,13 @@
|
|||
- [x] Mot de passe oublié
|
||||
- [x] Rename auto des liens au changement de path
|
||||
- [x] Filtrage de lien avec le header id
|
||||
- [ ] Editeur de graphe
|
||||
- [x] Editeur de graphe
|
||||
- [ ] Autocomplete des liens dans l'editeur
|
||||
- [ ] Embed de lien (le ![[]] de Obsidian)
|
||||
- [ ] Rework la structure projet
|
||||
- [ ] Limite de taille par projet (100 Mo ?)
|
||||
- [ ] Création de projet (!!! limité à 1 par personne au début)
|
||||
- [ ] Limite de taille par projet (50 Mo ?)
|
||||
- [ ] Tags et recherche
|
||||
- [ ] Création de projet (!!! limité à 1 par personne au début)
|
||||
- [ ] Type de fichier -> Map
|
||||
- [ ] Type de fichier -> Timeline
|
||||
- [ ] Type de fichier -> Trello
|
||||
|
|
@ -10,6 +10,6 @@ type MarkdownPreferences = {
|
|||
editing: 'split' |'reading' | 'editing';
|
||||
};
|
||||
type CanvasPreferences = {
|
||||
snap: boolean;
|
||||
size?: number;
|
||||
gridSnap: boolean;
|
||||
neighborSnap: boolean;
|
||||
};
|
||||
Loading…
Reference in New Issue