Files
Avtex/app/components/FlowEditor.vue
T

750 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-screen w-full flex bg-slate-950">
<!-- Node Palette -->
<div v-if="userRole !== 'viewer'" class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20">
<div class="p-3 border-b border-slate-700">
<h3 class="text-sm font-bold text-slate-200">Узлы</h3>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
<div v-for="nt in nodeTypes" :key="nt.type" draggable="true" @dragstart="onDragStart($event, nt)"
class="p-2 bg-slate-800 rounded cursor-move hover:bg-slate-700 border border-slate-600 text-xs select-none">
<div class="flex items-center gap-2">
<span>{{ nt.icon }}</span>
<div>
<div class="font-medium text-slate-200">{{ nt.name }}</div>
<div class="text-[10px] text-slate-500">{{ nt.description }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Canvas Area -->
<div class="flex-1 flex flex-col relative min-w-0">
<!-- Toolbar -->
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20">
<input v-model="canvasName" @change="saveCanvas" :disabled="userRole === 'viewer'" class="text-sm font-semibold text-slate-300 mr-2 bg-transparent border border-transparent hover:border-slate-600 focus:border-blue-500 focus:bg-slate-800 outline-none rounded px-2 py-1 w-48 transition" title="Переименовать карту" />
<!-- Group Selection -->
<div v-if="userRole !== 'viewer'" class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded px-2 py-1">
<span class="text-[10px] text-slate-500 uppercase font-bold mr-1">Группа:</span>
<select v-model="selectedGroupId" @change="saveCanvas" class="bg-transparent text-xs text-slate-300 outline-none cursor-pointer hover:text-white transition">
<option :value="null">Личная (нет)</option>
<option v-for="g in groupsList" :key="g.id" :value="g.id">
👥 {{ g.name }}
</option>
</select>
</div>
<span v-if="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
● Несохранено
</span>
<template v-if="userRole !== 'viewer'">
<button @click="addNodeAtCenter" class="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs">+ Узел</button>
<button @click="saveCanvas" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs">Сохранить</button>
<button v-if="userRole === 'owner' || userRole === 'admin'" @click="deleteCanvas" class="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition" title="Удалить карту">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<button @click="deleteSelected" :disabled="!selectedNode" class="px-2 py-1 bg-red-600 hover:bg-red-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded text-xs">Удалить</button>
</template>
<button @click="exportObsidian" class="px-2 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded text-xs">Экспорт</button>
<div class="flex-1"></div>
<button v-if="userRole !== 'viewer'" @click="manualGitSync" :disabled="gitSync.isSyncing.value"
class="px-2 py-1 bg-orange-600 hover:bg-orange-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded text-xs flex items-center gap-1">
<span>{{ gitSync.syncStatusIcon.value }}</span>
Git
</button>
<button @click="fitView" class="px-2 py-1 bg-slate-700 hover:bg-slate-600 text-slate-200 rounded text-xs">Расположить</button>
</div>
<!-- Vue Flow Canvas -->
<div class="flex-1 relative w-full h-full" @drop="onDrop" @dragover.prevent>
<VueFlow v-model="elements" :default-zoom="0.8" :min-zoom="0.2" :max-zoom="2" fit-view-on-init
:nodes-draggable="userRole !== 'viewer'"
:nodes-connectable="userRole !== 'viewer'"
:elements-selectable="true"
@node-double-click="onNodeDoubleClick" @node-click="onNodeClick" @pane-click="onPaneClick" @connect="onConnect"
@node-drag-start="onNodeDragStart" @node-drag="onNodeDrag" @node-drag-stop="onNodeDragStop"
class="bg-slate-950 w-full h-full">
<Background pattern-color="#334155" :gap="20" />
<Controls />
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
<template #node-text="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="text" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
</template>
<template #node-file="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="file" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
</template>
<template #node-task="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="task" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
</template>
<template #node-ai="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="ai" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
</template>
<template #node-webhook="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="webhook" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
</template>
<template #node-git="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="git" :selected="selectedNode?.id === id" @update="updateNodeData" />
</template>
</VueFlow>
</div>
</div>
<!-- Properties Panel -->
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20">
<div class="p-3 border-b border-slate-700">
<h3 class="text-sm font-bold text-slate-300">Свойства</h3>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div v-if="selectedNode" class="space-y-3">
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип узла</label>
<select v-model="selectedNode.type" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option v-for="nt in nodeTypes" :key="nt.type" :value="nt.type">
{{ nt.icon }} {{ nt.name }}
</option>
</select>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
<input v-model="selectedNode.data.label" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.data.text !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
<textarea v-model="selectedNode.data.text" @blur="commitNodeChange" rows="4" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div v-if="selectedNode.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
<input v-model="selectedNode.data.file" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
<select v-model="selectedNode.data.state" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="todo">📋 К выполнению (To Do)</option>
<option value="in_progress">⏳ В работе (In Progress)</option>
<option value="review">👀 На проверке (Review)</option>
<option value="completed">✅ Завершено (Done)</option>
<option value="blocked">⛔ Заблокировано (Blocked)</option>
</select>
</div>
<div v-if="selectedNode.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
<select v-model="selectedNode.data.priority" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="low">🧊 Низкий</option>
<option value="medium">⚡ Средний</option>
<option value="high">🔥 Высокий</option>
</select>
</div>
<div v-if="selectedNode.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
<select v-model="selectedNode.data.assigned_to" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="">Не назначено</option>
<option v-for="u in usersList" :key="u.username" :value="u.username">
{{ u.displayName || u.username }}
</option>
</select>
</div>
<div v-if="selectedNode.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
<input v-model="selectedNode.data.alarm" @blur="commitNodeChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.type === 'webhook'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Webhook URL</label>
<input v-model="selectedNode.data.url" @blur="commitNodeChange" placeholder="https://api.example.com/..." class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.type === 'webhook'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Метод</label>
<select v-model="selectedNode.data.method" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
<div v-if="selectedNode.type === 'ai'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Промпт (Инструкция)</label>
<textarea v-model="selectedNode.data.prompt" @blur="commitNodeChange" rows="3" placeholder="Что должен сделать ИИ? (например: Отредактируй этот текст...)" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div v-if="selectedNode.data.script_id !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
<input v-model="selectedNode.data.script_id" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none font-mono" />
</div>
<div v-if="selectedNode.type === 'file' || selectedNode.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ссылка на Карту</label>
<select v-model="selectedNode.data.file" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none">
<option value="">Не выбрано</option>
<option v-for="c in canvasesList" :key="c.id" :value="c.id">
🗺️ {{ c.name || c.id }}
</option>
</select>
</div>
<div v-if="selectedNode.data.prompt !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
<textarea v-model="selectedNode.data.prompt" @blur="commitNodeChange" rows="3" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ширина</label>
<input v-model.number="selectedNode.data.width" @blur="commitNodeChange" type="number" min="100" max="800" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Высота</label>
<input v-model.number="selectedNode.data.height" @blur="commitNodeChange" type="number" min="50" max="600" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
</div>
</div>
<div v-else class="text-xs text-slate-500 text-center mt-8">
Выберите узел для редактирования
</div>
</div>
</div>
</div>
</template>
<!-- Force rebuild to fix Vite 404 issue -->
<script setup>
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const props = defineProps({
canvasId: { type: String, default: 'demo' }
})
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
const gitSync = useGitSync()
const { isDirty, trackChanges, markAsSaved } = useUnsavedChanges()
const auth = useAuth()
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
const elements = ref([])
const selectedNode = ref(null)
const canvasName = ref(props.canvasId)
const selectedGroupId = ref(null)
const groupsList = ref([])
const userRole = ref('viewer')
const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
{ type: 'file', name: 'Файл', icon: '📄', description: 'Ссылка на файл' },
{ type: 'task', name: 'Задача', icon: '✅', description: 'Задача с состоянием' },
{ type: 'ai', name: 'AI Узел', icon: '🤖', description: 'LLM запрос' },
{ type: 'webhook', name: 'Webhook', icon: '🔗', description: 'HTTP триггер' },
{ type: 'git', name: 'Git Синхр', icon: '🔄', description: 'Git синхронизация' }
]
// Load canvas from API
const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data.canvas)
canvasName.value = data.canvas.name || props.canvasId
selectedGroupId.value = data.canvas.groupId || null
userRole.value = data.role || 'viewer'
// Mark initial state as saved
markAsSaved(data.canvas)
}
// Transform Obsidian canvas to Vue Flow elements
const transformToFlowElements = (canvasData) => {
const nodes = (canvasData.nodes || []).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : node.type === 'task' ? 'task' : node.type === 'webhook' ? 'webhook' : 'text',
position: { x: Number(node.x) || 0, y: Number(node.y) || 0 },
data: {
...node,
width: node.width || 300,
height: node.height || 200,
label: node.label || node.text?.slice(0, 20) || node.file || 'Node',
// Ensure specific fields exist so the properties panel shows them
...(node.type === 'task' && {
text: node.text ?? '',
state: node.state ?? 'todo',
priority: node.priority ?? 'medium',
assigned_to: node.assigned_to ?? '',
alarm: node.alarm ?? '',
script_id: node.script_id ?? ''
}),
...(node.type === 'text' && { text: node.text ?? '' }),
...(node.type === 'file' && { file: node.file ?? '' }),
...(node.type === 'ai' && { prompt: node.prompt ?? '' }),
...(node.type === 'webhook' && {
script_id: node.script_id ?? '',
url: node.url ?? '',
method: node.method ?? 'POST'
}),
...(node.type === 'git' && {
repo: node.repo ?? '',
branch: node.branch ?? 'main',
message: node.message ?? ''
})
},
style: {
width: `${node.width || 300}px`,
height: `${node.height || 200}px`,
background: node.style?.background || '#1e293b',
border: `1px solid ${node.style?.border || '#334155'}`,
borderRadius: '8px'
}
}))
const edges = (canvasData.edges || []).map(edge => ({
id: edge.id,
source: edge.fromNode,
target: edge.toNode,
sourceHandle: edge.fromSide,
targetHandle: edge.toSide,
animated: true,
style: { stroke: '#475569', strokeWidth: 2 }
}))
return [...nodes, ...edges]
}
// Transform Vue Flow elements back to Obsidian canvas
const transformToObsidianCanvas = (flowElements) => {
const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : node.type === 'task' ? 'task' : node.type === 'ai' ? 'ai' : node.type === 'webhook' ? 'webhook' : node.type === 'git' ? 'git' : 'text',
x: Math.round(Number(node.position.x) || 0),
y: Math.round(Number(node.position.y) || 0),
width: node.data.width || 300,
height: node.data.height || 200,
text: node.data.text,
label: node.data.label,
file: node.data.file,
state: node.data.state,
priority: node.data.priority,
assigned_to: node.data.assigned_to,
alarm: node.data.alarm,
script_id: node.data.script_id,
prompt: node.data.prompt,
url: node.data.url,
method: node.data.method,
repo: node.data.repo,
branch: node.data.branch,
message: node.data.message,
style: {
background: node.style?.background || '#1e293b',
border: node.style?.border?.replace('1px solid ', '') || '#334155'
}
}))
const edges = flowElements.filter(el => el.source && el.target).map(edge => ({
id: edge.id,
fromNode: edge.source,
fromSide: edge.sourceHandle || 'right',
toNode: edge.target,
toSide: edge.targetHandle || 'left'
}))
return { nodes, edges }
}
// Drag and drop handlers
const onDragStart = (event, nodeType) => {
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
}
const onDrop = (event) => {
const data = JSON.parse(event.dataTransfer.getData('application/vueflow'))
if (!data) return
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
createNode(data.type, x, y)
}
// Create new node
const createNode = (type, x, y) => {
const id = `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const nodeData = {
id,
type,
position: { x: x - 150, y: y - 100 },
data: {
width: 300,
height: 200,
label: `${type.charAt(0).toUpperCase() + type.slice(1)} Node`
}
}
// Add type-specific defaults
switch (type) {
case 'text':
nodeData.data.text = '# New Note\n\nEnter your text here'
break
case 'file':
nodeData.data.file = 'docs/file.md'
break
case 'task':
nodeData.data.state = 'todo'
nodeData.data.priority = 'medium'
nodeData.data.assigned_to = ''
nodeData.data.alarm = ''
nodeData.data.script_id = ''
break
case 'ai':
nodeData.data.prompt = 'Analyze this content'
break
case 'webhook':
nodeData.data.url = 'https://api.example.com'
nodeData.data.method = 'POST'
break
case 'git':
nodeData.data.repo = 'xflow-maps'
nodeData.data.branch = 'main'
nodeData.data.message = 'Update mind map'
break
}
elements.value.push(nodeData)
}
// Node selection and editing
const onNodeClick = ({ node }) => {
selectedNode.value = node
}
const onPaneClick = () => {
selectedNode.value = null
}
const updateNodeData = ({ id, data }) => {
const node = elements.value.find(el => el.id === id)
if (node) {
node.data = { ...node.data, ...data }
}
}
const commitNodeChange = () => {
if (selectedNode.value) {
// Trigger reactivity
const idx = elements.value.findIndex(el => el.id === selectedNode.value.id)
if (idx !== -1) {
elements.value[idx].data = { ...selectedNode.value.data }
elements.value[idx].style = { ...selectedNode.value.style }
elements.value[idx].type = selectedNode.value.type
elements.value.splice(idx, 1, { ...elements.value[idx] })
}
// Track changes
trackChanges(transformToObsidianCanvas(elements.value))
}
}
// Node actions
const addNodeAtCenter = () => {
createNode('text', 400, 300)
}
const deleteSelected = () => {
if (selectedNode.value) {
elements.value = elements.value.filter(el => el.id !== selectedNode.value.id)
selectedNode.value = null
}
}
const onNodeDragStart = (_event) => {}
const onNodeDrag = (_event) => {}
const onNodeDragStop = (event) => {
const node = event.node
// Update node data position
if (node.data) {
node.data.x = node.position.x
node.data.y = node.position.y
}
// Track changes
trackChanges(transformToObsidianCanvas(elements.value))
console.log('Node drag stopped:', node.id, 'at position:', node.position)
}
const onNodeDoubleClick = (event) => {
const node = event.node
// 1. Cross-Canvas Navigation Logic
const targetId = node.data?.file || node.data?.url || ''
if (targetId && (node.type === 'file' || targetId.includes('.canvas') || targetId.startsWith('/canvas/'))) {
const cleanId = targetId.replace('.canvas', '').replace('/canvas/', '')
navigateTo(`/canvas/${cleanId}`)
return
} else if (node.type === 'file' && !targetId) {
alert('У этого узла еще нет ссылки на другую карту. Выберите карту в панели свойств (справа).')
return
}
// 2. Task Toggle Logic (fallback)
if (node.type === 'task') {
const currentState = node.data?.state || 'todo'
const nextStateMap = {
'todo': 'in_progress',
'in_progress': 'review',
'review': 'completed',
'completed': 'todo',
'blocked': 'todo'
}
const nextState = nextStateMap[currentState] || 'todo'
updateNodeData({ id: node.id, data: { state: nextState } })
}
}
const executeNode = async ({ id, type }) => {
console.log(`[FlowEditor] Executing node ${id} of type ${type}`)
// Collect context from incoming nodes
const incomingEdges = elements.value.filter(el => el.target === id)
const contextNodes = incomingEdges.map(edge => {
const sourceNode = elements.value.find(el => el.id === edge.source)
return {
id: sourceNode?.id,
type: sourceNode?.type,
text: sourceNode?.data?.text || sourceNode?.data?.label || ''
}
}).filter(n => n.text)
try {
const res = await $fetch(`/api/nodes/${id}/execute`, {
method: 'POST',
body: {
canvasId: props.canvasId,
context: contextNodes
},
headers: authHeaders.value
})
if (res.success) {
console.log(`[FlowEditor] Execution successful:`, res.result)
// Перезагружаем данные, чтобы увидеть результат
await loadData()
}
} catch (err) {
console.error(`[FlowEditor] Execution failed:`, err)
alert('Ошибка при выполнении узла: ' + (err.message || 'Неизвестная ошибка'))
}
}
const onConnect = (connection) => {
const edge = {
id: `edge-${Date.now()}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
animated: true,
style: { stroke: '#475569', strokeWidth: 2 }
}
elements.value.push(edge)
}
// Canvas actions (moved to enhanced version below with Git sync)
const exportObsidian = () => {
const obsidianCanvas = transformToObsidianCanvas(elements.value)
const blob = new Blob([JSON.stringify(obsidianCanvas, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.canvasId}.canvas`
a.click()
URL.revokeObjectURL(url)
}
const deleteCanvas = async () => {
if (!confirm('Вы уверены, что хотите полностью удалить эту карту? Это действие необратимо.')) return
try {
const res = await fetch(`/api/canvas/${props.canvasId}`, {
method: 'DELETE',
headers: authHeaders.value
})
if (res.ok) {
navigateTo('/canvases')
} else {
const err = await res.json()
alert('Ошибка при удалении: ' + (err.message || 'неизвестная ошибка'))
}
} catch (err) {
console.error('Delete error:', err)
alert('Не удалось удалить карту')
}
}
const fitView = () => {
const padding = 40
const cols = 3
// Read actual unscaled sizes from DOM using offsetWidth/offsetHeight
// These are NOT affected by CSS transforms (zoom)
const domSizes = {}
const domNodes = document.querySelectorAll('.vue-flow__node')
domNodes.forEach(el => {
const id = el.getAttribute('data-id')
if (id) {
domSizes[id] = { width: el.offsetWidth, height: el.offsetHeight }
}
})
const nodes = elements.value.filter(el => el.id && !el.source && !el.target)
// Get real unscaled size from DOM
const getSize = (node) => {
const dom = domSizes[node.id]
if (dom && dom.width > 0 && dom.height > 0) {
return dom
}
return { width: node.data?.width || 320, height: node.data?.height || 200 }
}
// Grid layout: row by row, max height per row
const placed = []
let y = padding
for (let i = 0; i < nodes.length; i += cols) {
const rowNodes = nodes.slice(i, i + cols)
let x = padding
let rowMaxHeight = 0
for (const node of rowNodes) {
const size = getSize(node)
placed.push({ id: node.id, x, y, w: size.width, h: size.height })
x += size.width + padding
rowMaxHeight = Math.max(rowMaxHeight, size.height)
}
y += rowMaxHeight + padding
}
// Apply
elements.value = elements.value.map(el => {
if (el.source && el.target) return el
const p = placed.find(n => n.id === el.id)
if (!p) return el
return {
...el,
position: { x: p.x, y: p.y },
data: { ...el.data, x: p.x, y: p.y }
}
})
}
// Git synchronization functions
const createGitRepo = async () => {
try {
await gitSync.createRepository(props.canvasId, 'personal')
console.log('Git repository created successfully')
} catch (error) {
console.error('Failed to create Git repository:', error)
}
}
const manualGitSync = async () => {
try {
const canvasData = transformToObsidianCanvas(elements.value)
await gitSync.autoSyncCanvas(props.canvasId, canvasData)
} catch (error) {
console.error('Manual Git sync failed:', error)
}
}
// Enhanced save function with Git sync
const saveCanvas = async () => {
try {
console.log('[DEBUG saveCanvas] Starting save...')
const obsidianCanvas = transformToObsidianCanvas(elements.value)
console.log('[DEBUG saveCanvas] Sending PUT request...')
const res = await $fetch(`/api/canvas/${props.canvasId}`, {
method: 'PUT',
body: { ...obsidianCanvas, name: canvasName.value, groupId: selectedGroupId.value },
headers: authHeaders.value
})
console.log('[DEBUG saveCanvas] PUT request successful!', res)
// Mark as saved
markAsSaved(obsidianCanvas)
console.log('[DEBUG saveCanvas] Marked as saved in UI')
// Auto-sync to Git if repository exists
await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas)
console.log('Canvas saved successfully')
} catch (err) {
console.error('Failed to save canvas:', err)
}
}
const usersList = ref([])
const canvasesList = ref([])
// Load data on mount
onMounted(async () => {
await loadData()
// Check for existing Git repository
await gitSync.checkRepository(props.canvasId)
// Load users for assignee dropdown
try {
const res = await fetch('/api/users', {
headers: authHeaders.value
})
if (res.ok) {
usersList.value = await res.json()
}
} catch (e) {
console.error('Failed to load users', e)
}
// Load canvases for cross-link dropdown
try {
const res = await fetch('/api/canvas', {
headers: authHeaders.value
})
if (res.ok) {
const data = await res.json()
canvasesList.value = data.canvases || []
}
} catch (e) {
console.error('Failed to load canvases', e)
}
// Load groups for assignment
try {
const data = await $fetch('/api/groups')
groupsList.value = data.groups || []
} catch (e) {
console.error('Failed to load groups', e)
}
})
</script>
<style scoped>
:deep(.vue-flow__node) {
font-family: ui-sans-serif, system-ui, sans-serif;
height: auto !important;
}
:deep(.vue-flow__edge) {
stroke: #475569;
}
:deep(.vue-flow__controls) {
background: #1e293b;
border: 1px solid #334155;
}
:deep(.vue-flow__controls button) {
background: #334155;
border: none;
color: #e2e8f0;
}
:deep(.vue-flow__controls button:hover) {
background: #475569;
}
</style>