Add grid snapping, @TODO: Add settings popup with grid settings + render grid.

This commit is contained in:
Peaceultime 2025-01-28 17:55:47 +01:00
parent 3f04bb3d0c
commit 0b1809c3f6
8 changed files with 2724 additions and 29 deletions

2656
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@ -63,12 +63,23 @@ import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.utils'; import { clamp } from '#shared/general.utils';
import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas'; import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas';
const canvas = defineModel<CanvasContent>({ required: true, }); const canvas = defineModel<CanvasContent>({ required: true });
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5); const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
const focusing = ref<Element>(), editing = ref<Element>(); const focusing = ref<Element>(), editing = ref<Element>();
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef'); const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
const nodes = useTemplateRef<NodeEditor[]>('nodes'), edges = useTemplateRef<EdgeEditor[]>('edges'); 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 focused = computed(() => focusing.value ? focusing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === focusing.value!.id) : edges.value?.find(e => !!e && e.id === focusing.value!.id) : undefined), edited = computed(() => editing.value ? editing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === editing.value!.id) : edges.value?.find(e => !!e && e.id === editing.value!.id) : undefined); const 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);
@ -618,7 +629,7 @@ useShortcuts({
</div> </div>
</div> </div>
<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)" /> <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" />
</div> </div>
<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)" /> <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)" />

View File

@ -34,14 +34,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Direction } from '#shared/canvas.util'; import { gridSnap, type Direction } from '#shared/canvas.util';
import type { Element } from '../CanvasEditor.vue'; import type { Element } from '../CanvasEditor.vue';
import FakeA from '../prose/FakeA.vue'; import FakeA from '../prose/FakeA.vue';
import type { CanvasNode } from '~/types/canvas'; import type { CanvasNode } from '~/types/canvas';
const { node, zoom } = defineProps<{ const { node, zoom, snapping, grid } = defineProps<{
node: CanvasNode node: CanvasNode
zoom: number zoom: number
snapping: boolean
grid: number
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -80,14 +82,20 @@ function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
const startx = node.x, starty = node.y, startw = node.width, starth = node.height; const startx = node.x, starty = node.y, startw = node.width, starth = node.height;
let realx = node.x, realy = node.y, realw = node.width, realh = node.height;
const resizemove = (e: MouseEvent) => { const resizemove = (e: MouseEvent) => {
if(e.button !== 0) if(e.button !== 0)
return; return;
node.x += (e.movementX / zoom) * x; realx += (e.movementX / zoom) * x;
node.y += (e.movementY / zoom) * y; realy += (e.movementY / zoom) * y;
node.width += (e.movementX / zoom) * w; realw += (e.movementX / zoom) * w;
node.height += (e.movementY / zoom) * h; realh += (e.movementY / zoom) * h;
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 resizeend = (e: MouseEvent) => { const resizeend = (e: MouseEvent) => {
if(e.button !== 0) if(e.button !== 0)
@ -126,12 +134,16 @@ function unselect() {
} }
let lastx = 0, lasty = 0; let lastx = 0, lasty = 0;
let realx = 0, realy = 0;
const dragmove = (e: MouseEvent) => { const dragmove = (e: MouseEvent) => {
if(e.button !== 0) if(e.button !== 0)
return; return;
node.x += e.movementX / zoom; realx += e.movementX / zoom;
node.y += e.movementY / zoom; realy += e.movementY / zoom;
node.x = snapping ? gridSnap(realx, grid) : realx;
node.y = snapping ? gridSnap(realy, grid) : realy;
}; };
const dragend = (e: MouseEvent) => { const dragend = (e: MouseEvent) => {
if(e.button !== 0) if(e.button !== 0)
@ -148,6 +160,7 @@ const dragstart = (e: MouseEvent) => {
return; return;
lastx = node.x, lasty = node.y; lastx = node.x, lasty = node.y;
realx = node.x, realy = node.y;
window.addEventListener('mousemove', dragmove, { passive: true }); window.addEventListener('mousemove', dragmove, { passive: true });
window.addEventListener('mouseup', dragend, { passive: true }); window.addEventListener('mouseup', dragend, { passive: true });

View File

@ -11,44 +11,45 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@iconify/vue": "^4.3.0", "@iconify/vue": "^4.3.0",
"@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/sitemap": "^7.0.1", "@nuxtjs/sitemap": "^7.2.3",
"@nuxtjs/tailwindcss": "^6.12.2", "@nuxtjs/tailwindcss": "^6.13.1",
"@vueuse/gesture": "^2.0.0", "@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^11.3.0", "@vueuse/math": "^12.5.0",
"@vueuse/nuxt": "^11.3.0", "@vueuse/nuxt": "^12.5.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"drizzle-orm": "^0.35.3", "drizzle-orm": "^0.38.4",
"hast": "^1.0.0", "hast": "^1.0.0",
"hast-util-heading": "^3.0.0", "hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0", "hast-util-heading-rank": "^3.0.0",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2", "mdast-util-find-and-replace": "^3.0.2",
"nodemailer": "^6.9.16", "nodemailer": "^6.10.0",
"nuxt": "^3.15.0", "nuxt": "3.15.1",
"nuxt-security": "^2.1.5", "nuxt-security": "^2.1.5",
"radix-vue": "^1.9.12", "radix-vue": "^1.9.12",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-ofm": "link:remark-ofm",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.1",
"rollup-plugin-postcss": "^4.0.2", "rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-vue": "^6.0.0", "rollup-plugin-vue": "^6.0.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vue": "latest", "vue": "^3.5.13",
"vue-router": "latest", "vue-router": "^4.5.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.14", "@types/bun": "^1.2.0",
"@types/lodash.capitalize": "^4.2.9", "@types/lodash.capitalize": "^4.2.9",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/unist": "^3.0.3", "@types/unist": "^3.0.3",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.8.1",
"bun-types": "^1.1.42", "bun-types": "^1.2.0",
"drizzle-kit": "^0.26.2", "drizzle-kit": "^0.30.2",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"rehype-stringify": "^10.0.1" "rehype-stringify": "^10.0.1"
} }

View File

@ -89,8 +89,8 @@
</div> </div>
<DraggableTree class="ps-4 pe-2 xl:text-base text-sm" <DraggableTree class="ps-4 pe-2 xl:text-base text-sm"
:items="navigation ?? undefined" :get-key="(item: Partial<TreeItemEditable>) => item.path !== undefined ? getPath(item as TreeItemEditable) : ''" @updateTree="drop" :items="navigation ?? undefined" :get-key="(item: Partial<TreeItemEditable>) => item.path !== undefined ? getPath(item as TreeItemEditable) : ''" @updateTree="drop"
v-model="selected" :defaultExpanded="defaultExpanded" > v-model="selected" :defaultExpanded="defaultExpanded" :get-children="(item: Partial<TreeItemEditable>) => item.type === 'folder' ? item.children : undefined" >
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, item }"> <template #default="{ handleToggle, handleSelect, isExpanded, isDragging, item }">
<div class="flex flex-1 items-center px-2 max-w-full pe-4" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level - 0.5}em` }"> <div class="flex flex-1 items-center px-2 max-w-full pe-4" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level - 0.5}em` }">
<span class="py-2 px-2" @click="handleToggle" v-if="item.hasChildren" > <span class="py-2 px-2" @click="handleToggle" v-if="item.hasChildren" >
<Icon :icon="isExpanded ? 'lucide:folder-open' : 'lucide:folder'"/> <Icon :icon="isExpanded ? 'lucide:folder-open' : 'lucide:folder'"/>
@ -148,7 +148,7 @@
<div class="flex flex-row justify-between items-center gap-x-4"> <div class="flex flex-row justify-between items-center gap-x-4">
<div v-if="selected.customPath" class="flex lg:items-center truncate"> <div v-if="selected.customPath" class="flex lg:items-center truncate">
<pre class="md:text-base text-sm truncate" style="direction: rtl">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}</pre> <pre class="md:text-base text-sm truncate" style="direction: rtl">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}</pre>
<TextInput v-model="selected.name" @input="(e) => { <TextInput v-model="selected.name" @input="(e: Event) => {
if(selected && selected.customPath) if(selected && selected.customPath)
{ {
selected.name = parsePath(selected.name); selected.name = parsePath(selected.name);
@ -192,7 +192,7 @@
<span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de carte en cours de développement</ProseH3></span> <span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de carte en cours de développement</ProseH3></span>
</template> </template>
<template v-else-if="selected.type === 'file'"> <template v-else-if="selected.type === 'file'">
<span>Modifier le contenu :</span><input type="file" @change="(e) => console.log((e.target as HTMLInputElement).files?.length)" /> <span>Modifier le contenu :</span><input type="file" @change="(e: Event) => console.log((e.target as HTMLInputElement).files?.length)" />
</template> </template>
</div> </div>
</div> </div>
@ -206,7 +206,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item'; import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
import { convertContentFromText, convertContentToText, parsePath } from '#shared/general.utils'; import { convertContentFromText, convertContentToText, DEFAULT_CONTENT, parsePath } from '#shared/general.utils';
import type { CanvasContent, ExploreContent, FileType, TreeItem } from '~/types/content'; import type { CanvasContent, ExploreContent, FileType, TreeItem } from '~/types/content';
import { iconByType } from '#shared/general.utils'; import { iconByType } from '#shared/general.utils';
import FakeA from '~/components/prose/FakeA.vue'; import FakeA from '~/components/prose/FakeA.vue';
@ -418,7 +418,7 @@ function add(type: FileType): void
const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i); const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
const title = `Nouveau${news.length > 0 ? ' (' + news.length +')' : ''}`; const title = `Nouveau${news.length > 0 ? ' (' + news.length +')' : ''}`;
const item: TreeItemEditable = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: type === 'folder' ? [] : undefined, customPath: false, content: undefined, owner: -1, timestamp: new Date(), visit: 0 }; const item: TreeItemEditable = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: [], customPath: false, content: DEFAULT_CONTENT[type], owner: -1, timestamp: new Date(), visit: 0 };
if(!selected.value) if(!selected.value)
{ {
@ -518,6 +518,7 @@ function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: st
} }
async function save(redirect: boolean): Promise<void> async function save(redirect: boolean): Promise<void>
{ {
//@ts-ignore
const map = (e: TreeItemEditable[]): TreeItemEditable[] => e.map(f => ({ ...f, content: f.content ? convertContentToText(f.type, f.content) : undefined, children: f.children ? map(f.children) : undefined })); const map = (e: TreeItemEditable[]): TreeItemEditable[] => e.map(f => ({ ...f, content: f.content ? convertContentToText(f.type, f.content) : undefined, children: f.children ? map(f.children) : undefined }));
saveStatus.value = 'pending'; saveStatus.value = 'pending';
try { try {
@ -532,6 +533,7 @@ async function save(redirect: boolean): Promise<void>
toaster.clear('error'); toaster.clear('error');
toaster.add({ type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 }); toaster.add({ type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 });
//@ts-ignore
complete.value = result as ExploreContent[]; complete.value = result as ExploreContent[];
if(redirect) router.go(-1); if(redirect) router.go(-1);
} catch(e: any) { } catch(e: any) {

View File

@ -82,4 +82,9 @@ export function getCenter(n: Position, i: Position, r: Position, o: Position, e:
x: s * n.x + l * r.x + c * o.x + u * i.x, x: s * n.x + l * r.x + c * o.x + u * i.x,
y: s * n.y + l * r.y + c * o.y + u * i.y y: s * n.y + l * r.y + c * o.y + u * i.y
}; };
}
export function gridSnap(value: number, grid: number): number
{
return Math.round(value / grid) * grid;
} }

View File

@ -1,6 +1,13 @@
import type { CanvasContent } from '~/types/canvas'; import type { CanvasContent } from '~/types/canvas';
import type { ContentMap, FileType } from '~/types/content'; import type { ContentMap, FileType } from '~/types/content';
export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]['content']> = {
map: {},
canvas: { nodes: [], edges: []},
markdown: '',
file: '',
folder: null,
}
export function unifySlug(slug: string | string[]): string export function unifySlug(slug: string | string[]): string
{ {
return (Array.isArray(slug) ? slug.join('/') : slug); return (Array.isArray(slug) ? slug.join('/') : slug);