ui i like most
This commit is contained in:
+175
-30
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, markRaw, watch } from 'vue'
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import type { Node, Edge, Connection } from '@vue-flow/core'
|
||||
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'
|
||||
|
||||
@@ -13,15 +13,18 @@ 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
|
||||
|
||||
// Sync server-push state into Vue Flow node data
|
||||
watch(
|
||||
() => studio.serverStatus,
|
||||
(status) => {
|
||||
@@ -36,31 +39,64 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function addNode(nodeType: string) {
|
||||
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: { x: 160 + (nodeCounter % 6) * 220, y: 80 + Math.floor(nodeCounter / 6) * 200 },
|
||||
position: pos,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: { nodeType, params: defaultParams(nodeType), serverState: undefined } as any,
|
||||
}
|
||||
// @ts-ignore TS2589: Vue Flow Node<T> generic is too deep for vue-tsc's instantiation limit
|
||||
// @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 } as Edge,
|
||||
{ ...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 }
|
||||
@@ -84,16 +120,29 @@ function clear() {
|
||||
<Toolbar @run="run" @stop="stop" @clear="clear" />
|
||||
<div class="canvas-area">
|
||||
<NodePalette @add-node="addNode" />
|
||||
<VueFlow
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
@@ -104,35 +153,96 @@ html, body, #app {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #11111b;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #cdd6f4;
|
||||
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: #11111b !important;
|
||||
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: #11111b !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ── Vue Flow: Edges — thin white wires ── */
|
||||
.vue-flow__edge {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
stroke: #89b4fa !important;
|
||||
stroke-width: 2 !important;
|
||||
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;
|
||||
animation: dashdraw 0.5s linear infinite;
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes dashdraw {
|
||||
to { stroke-dashoffset: -10; }
|
||||
/* ── 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>
|
||||
|
||||
@@ -143,15 +253,50 @@ html, body, #app {
|
||||
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 {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user