717 lines
29 KiB
Vue
717 lines
29 KiB
Vue
<template>
|
||
<div class="h-screen w-full flex bg-slate-950">
|
||
<!-- Node Palette -->
|
||
<div 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" 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-64 transition" title="Переименовать карту" />
|
||
<span v-if="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
|
||
● Несохранено
|
||
</span>
|
||
<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 @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="exportObsidian" class="px-2 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded text-xs">Экспорт</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>
|
||
<div class="flex-1"></div>
|
||
<button @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
|
||
@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 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)
|
||
canvasName.value = data.name || props.canvasId
|
||
// Mark initial state as saved
|
||
markAsSaved(data)
|
||
}
|
||
|
||
// 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 },
|
||
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)
|
||
}
|
||
})
|
||
</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>
|