Files
dmf-studio-ui/src/App.vue
T
2026-07-09 01:27:35 +03:00

302 lines
7.4 KiB
Vue

<script setup lang="ts">
import { ref, markRaw, watch } from 'vue'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import type { Node, Edge, Connection, XYPosition, EdgeMouseEvent } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import Toolbar from './components/Toolbar.vue'
import NodePalette from './components/NodePalette.vue'
import StudioNode from './components/nodes/StudioNode.vue'
import { useStudioStore } from './stores/studio'
import { buildGraph } from './graphBuilder'
import { defaultParams } from './nodeTypes'
import type { StudioNodeData } from './types'
const FLOW_ID = 'dmf-studio'
const { viewportHelper } = useVueFlow(FLOW_ID)
const studio = useStudioStore()
const nodeTypes = { studio: markRaw(StudioNode) }
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
const flowWrapperRef = ref<HTMLElement>()
let nodeCounter = 0
watch(
() => studio.serverStatus,
(status) => {
for (const node of nodes.value) {
const d = node.data as StudioNodeData
const serverState = (status[node.id] as StudioNodeData['serverState']) ?? undefined
if (d.serverState !== serverState) {
node.data = { ...d, serverState }
}
}
},
{ deep: true }
)
const DROP_OFFSET_X = 100
const DROP_OFFSET_Y = 20
function createNodeAt(nodeType: string, pos: XYPosition) {
nodeCounter++
const id = `${nodeType}-${nodeCounter}`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newNode: Node = {
id,
type: 'studio',
position: pos,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { nodeType, params: defaultParams(nodeType), serverState: undefined } as any,
}
// @ts-ignore TS2589
nodes.value = [...nodes.value, newNode]
}
function onDrop(event: DragEvent) {
const nodeType = event.dataTransfer?.getData('application/dmf-node-type')
if (!nodeType) return
const vp = viewportHelper.value
if (!vp) return
const flowPos = vp.screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
createNodeAt(nodeType, { x: flowPos.x - DROP_OFFSET_X, y: flowPos.y - DROP_OFFSET_Y })
}
function onDragOver(event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
}
function addNode(nodeType: string) {
const vp = viewportHelper.value
if (vp && flowWrapperRef.value) {
const rect = flowWrapperRef.value.getBoundingClientRect()
const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
const flowPos = vp.screenToFlowCoordinate(center)
createNodeAt(nodeType, { x: flowPos.x - DROP_OFFSET_X, y: flowPos.y - DROP_OFFSET_Y })
} else {
createNodeAt(nodeType, { x: 200, y: 80 })
}
}
function onConnect(connection: Connection) {
const srcHandle = connection.sourceHandle ?? ''
const tgtHandle = connection.targetHandle ?? ''
if (srcHandle.startsWith('video') !== tgtHandle.startsWith('video')) return
const kind = srcHandle.startsWith('video') ? 'video' : 'audio'
edges.value = [
...edges.value,
{ ...connection, id: `e-${Date.now()}`, animated: true, class: `edge-${kind}` } as Edge,
]
}
function onEdgeClick(event: EdgeMouseEvent) {
edges.value = edges.value.filter(e => e.id !== event.edge.id)
}
function run() {
if (nodes.value.length === 0) { alert('Add at least one node first.'); return }
if (edges.value.length === 0) { alert('Connect the nodes with at least one wire first.'); return }
const graph = buildGraph(nodes.value, edges.value)
studio.send({ type: 'load_graph', graph })
}
function stop() {
studio.send({ type: 'load_graph', graph: { nodes: [], edges: [] } })
}
function clear() {
nodes.value = []
edges.value = []
nodeCounter = 0
}
</script>
<template>
<div class="app-shell">
<Toolbar @run="run" @stop="stop" @clear="clear" />
<div class="canvas-area">
<NodePalette @add-node="addNode" />
<div
class="flow-wrapper"
ref="flowWrapperRef"
@dragover.prevent="onDragOver"
@drop.prevent="onDrop"
>
<VueFlow
:id="FLOW_ID"
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
class="flow-canvas"
:min-zoom="0.3"
:max-zoom="2"
fit-view-on-init
@connect="onConnect"
@edge-click="onEdgeClick"
/>
<div v-if="nodes.length === 0" class="canvas-hint">
<div class="hint-label">EMPTY WORKSPACE</div>
<div class="hint-text">DRAG NODES FROM LEFT PANEL</div>
</div>
</div>
</div>
</div>
</template>
<style>
html, body, #app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: var(--bg-base);
font-family: var(--font-mono);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
*, *::before, *::after {
box-sizing: border-box;
}
/* ── Vue Flow: Canvas ── */
.vue-flow {
background-color: var(--bg-base) !important;
background-image:
radial-gradient(circle, #1a1a1a 1px, transparent 1px) !important;
background-size: 24px 24px !important;
}
.vue-flow__background {
background: transparent !important;
}
/* ── Vue Flow: Edges — thin white wires ── */
.vue-flow__edge {
cursor: pointer;
}
.vue-flow__edge-path {
stroke: #E6E6E6 !important;
stroke-width: 1.5 !important;
}
.vue-flow__edge.edge-audio .vue-flow__edge-path {
stroke-dasharray: 4 3 !important;
}
.vue-flow__edge.animated .vue-flow__edge-path {
stroke-dasharray: 5 4;
animation: dashflow 0.8s linear infinite;
}
.vue-flow__edge:hover .vue-flow__edge-path {
stroke: var(--accent) !important;
stroke-width: 2 !important;
}
@keyframes dashflow {
to { stroke-dashoffset: -18; }
}
/* ── Vue Flow: Handles ── */
.vue-flow__handle {
border-radius: 50% !important;
border: 1px solid var(--border-medium) !important;
}
/* ── Vue Flow: Selection — red dot indicator ── */
.vue-flow__node.selectable:focus,
.vue-flow__node.selectable:active {
outline: none !important;
}
.vue-flow__node.selected .studio-node {
border-color: var(--accent) !important;
}
/* ── Vue Flow: Controls ── */
.vue-flow__controls {
background: var(--bg-panel-1) !important;
border: 1px solid var(--border-thin) !important;
border-radius: 0 !important;
box-shadow: none !important;
margin: 8px !important;
}
.vue-flow__controls-button {
background: transparent !important;
border-bottom: 1px solid var(--border-thin) !important;
color: var(--text-secondary) !important;
border-radius: 0 !important;
}
.vue-flow__controls-button:hover {
background: var(--bg-elevated) !important;
color: var(--text-primary) !important;
}
.vue-flow__controls-button svg {
fill: currentColor !important;
}
</style>
<style scoped>
.app-shell {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-base);
}
.canvas-area {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.flow-wrapper {
flex: 1;
position: relative;
overflow: hidden;
}
.flow-canvas {
width: 100%;
height: 100%;
}
.canvas-hint {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: var(--z-hint);
gap: 8px;
}
.hint-label {
font-size: 12px;
font-weight: 400;
letter-spacing: 4px;
color: var(--text-dim);
}
.hint-text {
font-size: 10px;
letter-spacing: 2px;
color: var(--text-dim);
opacity: 0.7;
}
</style>