Implemented 2D visualization for notes using Vue Flow
This commit is contained in:
200
src/components/OrgTree/OrgNodeComponent.vue
Normal file
200
src/components/OrgTree/OrgNodeComponent.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="org-node-component">
|
||||
<div
|
||||
class="node-content"
|
||||
:class="[
|
||||
'bg-white border rounded-lg shadow-md p-3 min-w-48 max-w-72',
|
||||
`level-${data.level}`,
|
||||
{ 'editing': isEditing }
|
||||
]"
|
||||
@dblclick="startEditing"
|
||||
>
|
||||
<!-- Node Header -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-400">{{ '*'.repeat(data.level) }}</span>
|
||||
<button
|
||||
v-if="data.node.children.length > 0"
|
||||
@click="toggleExpanded"
|
||||
class="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{{ isExpanded ? '−' : '+' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex space-x-1">
|
||||
<span
|
||||
v-for="tag in data.node.tags"
|
||||
:key="tag"
|
||||
class="px-1 py-0.5 bg-blue-100 text-blue-800 text-xs rounded"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Title -->
|
||||
<div class="mb-2">
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="editTitle"
|
||||
@blur="saveTitle"
|
||||
@keyup.enter="saveTitle"
|
||||
@keyup.escape="cancelEdit"
|
||||
class="w-full font-medium bg-transparent border-b border-gray-300 focus:outline-none focus:border-blue-500"
|
||||
ref="titleInput"
|
||||
/>
|
||||
<h4
|
||||
v-else
|
||||
class="font-medium text-gray-900 cursor-pointer hover:text-blue-600 leading-tight"
|
||||
>
|
||||
{{ data.node.title }}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Node Content -->
|
||||
<div v-if="data.node.content" class="text-sm text-gray-600">
|
||||
<textarea
|
||||
v-if="isEditingContent"
|
||||
v-model="editContent"
|
||||
@blur="saveContent"
|
||||
@keyup.escape="cancelContentEdit"
|
||||
class="w-full bg-transparent border border-gray-300 rounded p-1 text-xs focus:outline-none focus:border-blue-500"
|
||||
rows="2"
|
||||
ref="contentInput"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
@dblclick="startEditingContent"
|
||||
class="cursor-pointer hover:bg-gray-50 p-1 rounded text-xs leading-tight"
|
||||
>
|
||||
{{ data.node.content.substring(0, 100) }}{{ data.node.content.length > 100 ? '...' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Children count indicator -->
|
||||
<div v-if="data.node.children.length > 0" class="mt-2 text-xs text-gray-400">
|
||||
{{ data.node.children.length }} child{{ data.node.children.length !== 1 ? 'ren' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle type="target" :position="Position.Left" class="handle-left" />
|
||||
<Handle type="source" :position="Position.Right" class="handle-right" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { OrgNode } from '@/types/org'
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
node: OrgNode
|
||||
level: number
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
update: [node: OrgNode]
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isEditingContent = ref(false)
|
||||
const editTitle = ref('')
|
||||
const editContent = ref('')
|
||||
const titleInput = ref<HTMLInputElement>()
|
||||
const contentInput = ref<HTMLTextAreaElement>()
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
async function startEditing() {
|
||||
isEditing.value = true
|
||||
editTitle.value = props.data.node.title
|
||||
await nextTick()
|
||||
titleInput.value?.focus()
|
||||
titleInput.value?.select()
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
isEditing.value = false
|
||||
if (editTitle.value !== props.data.node.title) {
|
||||
emit('update', { ...props.data.node, title: editTitle.value })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
editTitle.value = props.data.node.title
|
||||
}
|
||||
|
||||
async function startEditingContent() {
|
||||
isEditingContent.value = true
|
||||
editContent.value = props.data.node.content
|
||||
await nextTick()
|
||||
contentInput.value?.focus()
|
||||
contentInput.value?.select()
|
||||
}
|
||||
|
||||
function saveContent() {
|
||||
isEditingContent.value = false
|
||||
if (editContent.value !== props.data.node.content) {
|
||||
emit('update', { ...props.data.node, content: editContent.value })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelContentEdit() {
|
||||
isEditingContent.value = false
|
||||
editContent.value = props.data.node.content
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.org-node-component {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.node-content {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.node-content:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.level-1 {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.level-2 {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.level-3 {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.level-4 {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.level-5 {
|
||||
border-left: 4px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.editing {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
.handle-left,
|
||||
.handle-right {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid #3b82f6;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
@@ -2,39 +2,95 @@
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">{{ file.name }}</h2>
|
||||
<button
|
||||
@click="exportFile"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="resetLayout"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
|
||||
>
|
||||
Reset Layout
|
||||
</button>
|
||||
<button
|
||||
@click="exportFile"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="org-tree">
|
||||
<OrgNode
|
||||
v-for="node in file.nodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
@update="updateNode"
|
||||
/>
|
||||
<div class="org-tree-2d h-96 border rounded-lg">
|
||||
<VueFlow
|
||||
:nodes="flowNodes"
|
||||
:edges="flowEdges"
|
||||
:node-types="nodeTypes"
|
||||
@nodes-change="onNodesChange"
|
||||
@edges-change="onEdgesChange"
|
||||
@node-click="onNodeClick"
|
||||
fit-view-on-init
|
||||
class="vue-flow-container"
|
||||
>
|
||||
<Background pattern-color="#e5e7eb" :gap="20" />
|
||||
<Controls />
|
||||
</VueFlow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OrgNode from './OrgNode.vue'
|
||||
import { ref, computed, markRaw } from 'vue'
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { generateNodeLayout } from '@/utils/nodeLayoutGenerator'
|
||||
import { generateOrgContent } from '@/utils/orgGenerator'
|
||||
import OrgNodeComponent from './OrgNodeComponent.vue'
|
||||
import type { OrgFile, OrgNode as IOrgNode } from '@/types/org'
|
||||
import type { FlowNode, FlowEdge } from '@/utils/nodeLayoutGenerator'
|
||||
|
||||
const props = defineProps<{
|
||||
file: OrgFile
|
||||
}>()
|
||||
|
||||
const nodeTypes = {
|
||||
orgNode: markRaw(OrgNodeComponent)
|
||||
}
|
||||
|
||||
const { nodes: initialNodes, edges: initialEdges } = generateNodeLayout(props.file.nodes)
|
||||
const flowNodes = ref<FlowNode[]>(initialNodes)
|
||||
const flowEdges = ref<FlowEdge[]>(initialEdges)
|
||||
|
||||
function onNodesChange(changes: any) {
|
||||
// Handle node position changes for dragging
|
||||
changes.forEach((change: any) => {
|
||||
if (change.type === 'position' && change.position) {
|
||||
const node = flowNodes.value.find(n => n.id === change.id)
|
||||
if (node) {
|
||||
node.position = change.position
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onEdgesChange(changes: any) {
|
||||
// Handle edge changes if needed
|
||||
console.log('Edges changed:', changes)
|
||||
}
|
||||
|
||||
function onNodeClick(event: any) {
|
||||
console.log('Node clicked:', event.node.data)
|
||||
}
|
||||
|
||||
function updateNode(updatedNode: IOrgNode) {
|
||||
// TODO: Implement node update logic
|
||||
console.log('Node updated:', updatedNode)
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
const { nodes: newNodes, edges: newEdges } = generateNodeLayout(props.file.nodes)
|
||||
flowNodes.value = newNodes
|
||||
flowEdges.value = newEdges
|
||||
}
|
||||
|
||||
function exportFile() {
|
||||
const content = generateOrgContent(props.file)
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
@@ -45,4 +101,29 @@ function exportFile() {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
|
||||
.vue-flow-container {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.vue-flow__node-orgNode {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vue-flow__edge.vue-flow__edge-smoothstep {
|
||||
stroke: #64748b;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vue-flow__edge.vue-flow__edge-smoothstep:hover {
|
||||
stroke: #3b82f6;
|
||||
stroke-width: 3;
|
||||
}
|
||||
</style>
|
||||
85
src/utils/nodeLayoutGenerator.ts
Normal file
85
src/utils/nodeLayoutGenerator.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { OrgNode } from '@/types/org'
|
||||
|
||||
export interface FlowNode {
|
||||
id: string
|
||||
type: 'orgNode'
|
||||
position: { x: number; y: number }
|
||||
data: {
|
||||
node: OrgNode
|
||||
level: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
type: 'smoothstep'
|
||||
}
|
||||
|
||||
export function generateNodeLayout(nodes: OrgNode[]): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
||||
const flowNodes: FlowNode[] = []
|
||||
const flowEdges: FlowEdge[] = []
|
||||
|
||||
let nodeCounter = 0
|
||||
|
||||
function processNode(
|
||||
node: OrgNode,
|
||||
level: number,
|
||||
parentId: string | null,
|
||||
angle: number,
|
||||
distance: number,
|
||||
centerX: number = 400,
|
||||
centerY: number = 300
|
||||
) {
|
||||
const nodeId = `node-${nodeCounter++}`
|
||||
|
||||
// Calculate position in a radial/organic layout
|
||||
const x = centerX + Math.cos(angle) * distance
|
||||
const y = centerY + Math.sin(angle) * distance
|
||||
|
||||
flowNodes.push({
|
||||
id: nodeId,
|
||||
type: 'orgNode',
|
||||
position: { x, y },
|
||||
data: {
|
||||
node,
|
||||
level
|
||||
}
|
||||
})
|
||||
|
||||
// Add edge from parent
|
||||
if (parentId) {
|
||||
flowEdges.push({
|
||||
id: `edge-${parentId}-${nodeId}`,
|
||||
source: parentId,
|
||||
target: nodeId,
|
||||
type: 'smoothstep'
|
||||
})
|
||||
}
|
||||
|
||||
// Process children in a radial pattern around this node
|
||||
const childCount = node.children.length
|
||||
if (childCount > 0) {
|
||||
const angleStep = (2 * Math.PI) / Math.max(childCount, 3)
|
||||
const childDistance = Math.max(150, distance * 0.7)
|
||||
|
||||
node.children.forEach((child, index) => {
|
||||
const childAngle = angle + (index - (childCount - 1) / 2) * angleStep * 0.8
|
||||
processNode(child, level + 1, nodeId, childAngle, childDistance, x, y)
|
||||
})
|
||||
}
|
||||
|
||||
return nodeId
|
||||
}
|
||||
|
||||
// Start with root nodes in a circular pattern
|
||||
const rootCount = nodes.length
|
||||
nodes.forEach((node, index) => {
|
||||
const angle = (2 * Math.PI * index) / rootCount
|
||||
const distance = rootCount > 1 ? 200 : 0
|
||||
processNode(node, 1, null, angle, distance)
|
||||
})
|
||||
|
||||
return { nodes: flowNodes, edges: flowEdges }
|
||||
}
|
||||
Reference in New Issue
Block a user