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:
Peaceultime 2025-02-06 23:36:55 +01:00
parent e2c18ff406
commit 939b9cbd28
15 changed files with 665 additions and 120 deletions

View File

@ -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>

View File

@ -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();
}
}
})

View File

@ -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<{

View File

@ -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 ?

View File

@ -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';

View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -103,8 +103,8 @@ export default defineNuxtConfig({
100: '#dadada',
},
accent: {
purple: '#8a5cf5',
blue: '#53aaf5',
purple: '#43A047',
blue: '#26C6DA',
},
}
}

View File

@ -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>

View File

@ -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":

332
shared/physics.util.ts Normal file
View File

@ -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;
}
}

View File

@ -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 ?)
- [ ] 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

4
types/general.d.ts vendored
View File

@ -10,6 +10,6 @@ type MarkdownPreferences = {
editing: 'split' |'reading' | 'editing';
};
type CanvasPreferences = {
snap: boolean;
size?: number;
gridSnap: boolean;
neighborSnap: boolean;
};