302 lines
7.4 KiB
Vue
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> |