Implemented 2D visualization for notes using Vue Flow

This commit is contained in:
Atharva Sawant
2024-03-08 11:23:47 +05:30
parent ffb5eeddf2
commit ce53a54dc5
488 changed files with 123675 additions and 20 deletions

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

View File

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

View 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 }
}