This commit is contained in:
itsrubberduck
2025-09-20 22:53:07 +02:00
3 changed files with 1052 additions and 154 deletions

View File

@@ -31,7 +31,7 @@
<div
v-for="node in preparedNodes"
:key="node.id"
class="absolute rounded-2xl border backdrop-blur transition-all duration-150"
class="group absolute rounded-2xl border backdrop-blur transition-all duration-150"
:class="[
node.selected ? 'ring-2 ring-cyan-400 shadow-xl shadow-cyan-500/20' : 'shadow-lg shadow-black/40',
node.dimmed ? 'opacity-30' : 'opacity-100',
@@ -41,6 +41,12 @@
@pointerdown.stop="(event) => onNodePointerDown(event, node)"
@dblclick.prevent="() => emit('select', node.id)"
>
<button class="node-add node-add--top" @click.stop="emit('add-before', node.id)">
<v-icon icon="mdi-plus" size="16" />
</button>
<button class="node-add node-add--bottom" @click.stop="emit('add-after', node.id)">
<v-icon icon="mdi-plus" size="16" />
</button>
<span class="node-connector node-connector--input" />
<span class="node-connector node-connector--output" />
<div class="flex h-full flex-col">
@@ -97,11 +103,44 @@
<v-icon icon="mdi-crosshairs-gps" size="18" />
</button>
</div>
<div
ref="minimapRef"
class="pointer-events-auto absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/40 p-3 shadow-lg"
@click="onMinimapClick"
>
<svg :width="minimapSize.width" :height="minimapSize.height" class="text-white/70">
<g>
<rect
v-for="node in preparedNodes"
:key="node.id"
:x="(node.layout.x - minimapBounds.minX) * minimapScale"
:y="(node.layout.y - minimapBounds.minY) * minimapScale"
:width="Math.max(6, node.width * minimapScale)"
:height="Math.max(6, node.height * minimapScale)"
:fill="node.selected ? '#22d3ee' : 'rgba(148, 163, 184, 0.45)'"
:opacity="node.selected ? 0.9 : 0.5"
rx="4"
ry="4"
/>
</g>
<rect
:x="viewportRect.x"
:y="viewportRect.y"
:width="viewportRect.width"
:height="viewportRect.height"
fill="rgba(34, 211, 238, 0.12)"
stroke="#22d3ee"
stroke-width="1.2"
rx="6"
ry="6"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type {
DecisionNodeModel,
DecisionNodeTransition,
@@ -160,6 +199,9 @@ const props = withDefaults(
const emit = defineEmits<{
(event: 'select', stateId: string): void
(event: 'navigate', stateId: string): void
(event: 'add-before', stateId: string): void
(event: 'add-after', stateId: string): void
(event: 'node-drag-start', stateId: string): void
(event: 'node-move', payload: { stateId: string; x: number; y: number }): void
(event: 'node-drop', payload: { stateId: string; x: number; y: number }): void
(event: 'update:pan', value: CanvasPan): void
@@ -169,6 +211,14 @@ const emit = defineEmits<{
const canvasRef = ref<HTMLDivElement | null>(null)
const currentPan = ref<CanvasPan>({ ...props.pan })
const currentZoom = ref(props.zoom)
const viewportSize = ref({ width: 0, height: 0 })
const minimapRef = ref<HTMLDivElement | null>(null)
const MINIMAP_WIDTH = 220
const MINIMAP_HEIGHT = 160
const MINIMAP_PADDING = 200
let resizeObserver: ResizeObserver | null = null
watch(
() => props.pan,
@@ -185,10 +235,75 @@ watch(
}
)
onMounted(() => {
if (!canvasRef.value) return
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
viewportSize.value = {
width: entry.contentRect.width,
height: entry.contentRect.height,
}
}
})
resizeObserver.observe(canvasRef.value)
const rect = canvasRef.value.getBoundingClientRect()
viewportSize.value = { width: rect.width, height: rect.height }
})
const transformStyle = computed(() => ({
transform: `translate(${currentPan.value.x}px, ${currentPan.value.y}px) scale(${currentZoom.value})`,
}))
const minimapBounds = computed(() => {
if (!preparedNodes.value.length) {
return { minX: 0, minY: 0, width: MINIMAP_WIDTH, height: MINIMAP_HEIGHT }
}
const minX = Math.min(...preparedNodes.value.map((node) => node.layout.x))
const minY = Math.min(...preparedNodes.value.map((node) => node.layout.y))
const maxX = Math.max(...preparedNodes.value.map((node) => node.layout.x + node.width))
const maxY = Math.max(...preparedNodes.value.map((node) => node.layout.y + node.height))
const width = Math.max(1, maxX - minX + MINIMAP_PADDING * 2)
const height = Math.max(1, maxY - minY + MINIMAP_PADDING * 2)
return {
minX: minX - MINIMAP_PADDING,
minY: minY - MINIMAP_PADDING,
width,
height,
}
})
const minimapScale = computed(() => {
const bounds = minimapBounds.value
const scaleX = MINIMAP_WIDTH / bounds.width
const scaleY = MINIMAP_HEIGHT / bounds.height
return Math.min(scaleX, scaleY)
})
const minimapSize = computed(() => {
const bounds = minimapBounds.value
const scale = minimapScale.value
return {
width: Math.max(80, bounds.width * scale),
height: Math.max(60, bounds.height * scale),
}
})
const viewportRect = computed(() => {
const bounds = minimapBounds.value
const scale = minimapScale.value
const zoom = currentZoom.value || 1
const viewWidth = viewportSize.value.width / zoom
const viewHeight = viewportSize.value.height / zoom
const worldX = (-currentPan.value.x) / zoom
const worldY = (-currentPan.value.y) / zoom
return {
x: (worldX - bounds.minX) * scale,
y: (worldY - bounds.minY) * scale,
width: Math.max(12, viewWidth * scale),
height: Math.max(12, viewHeight * scale),
}
})
const preparedNodes = computed(() =>
props.nodes.map((node) => {
const width = node.layout?.width ?? NODE_WIDTH
@@ -301,6 +416,26 @@ const gridStyle = computed(() => ({
opacity: 0.5,
}))
function focusNode(stateId: string) {
const node = preparedNodes.value.find((entry) => entry.id === stateId)
if (!node) return
const width = viewportSize.value.width || canvasBounds.value.width
const height = viewportSize.value.height || canvasBounds.value.height
const zoomX = width / (node.width + 160)
const zoomY = height / (node.height + 200)
const targetZoom = Math.min(2.5, Math.max(0.4, Math.min(zoomX, zoomY)))
currentZoom.value = targetZoom
const centerX = node.layout.x + node.width / 2
const centerY = node.layout.y + node.height / 2
const panX = width / 2 - centerX * targetZoom
const panY = height / 2 - centerY * targetZoom
currentPan.value = { x: panX, y: panY }
emit('update:zoom', targetZoom)
emit('update:pan', { x: panX, y: panY })
}
defineExpose({ focusNode })
function transitionClass(transition: DecisionNodeTransition) {
switch (transition.type) {
case 'ok':
@@ -510,6 +645,7 @@ let dragState: DragState | null = null
function onNodePointerDown(event: PointerEvent, node: ReturnType<typeof preparedNodes.value[number]>) {
if (event.button !== 0) return
emit('select', node.id)
emit('node-drag-start', node.id)
const rect = canvasRef.value?.getBoundingClientRect()
if (!rect) return
const canvasX = (event.clientX - rect.left - currentPan.value.x) / currentZoom.value
@@ -580,6 +716,23 @@ function resetView() {
emit('update:pan', { x: 0, y: 0 })
}
function onMinimapClick(event: MouseEvent) {
const container = minimapRef.value
if (!container) return
const rect = container.getBoundingClientRect()
const offsetX = event.clientX - rect.left
const offsetY = event.clientY - rect.top
const bounds = minimapBounds.value
const scale = minimapScale.value || 1
const worldX = offsetX / scale + bounds.minX
const worldY = offsetY / scale + bounds.minY
const zoom = currentZoom.value || 1
const panX = viewportSize.value.width / 2 - worldX * zoom
const panY = viewportSize.value.height / 2 - worldY * zoom
currentPan.value = { x: panX, y: panY }
emit('update:pan', { x: panX, y: panY })
}
function onWheel(event: WheelEvent) {
const rect = canvasRef.value?.getBoundingClientRect()
if (!rect) return
@@ -613,6 +766,7 @@ function nodeStyle(node: ReturnType<typeof preparedNodes.value[number]>) {
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onNodePointerMove)
window.removeEventListener('pointermove', onPanPointerMove)
resizeObserver?.disconnect()
})
</script>
@@ -621,6 +775,26 @@ onBeforeUnmount(() => {
@apply pointer-events-auto flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/40 text-white/80 transition hover:border-cyan-400 hover:text-cyan-200;
}
.node-add {
@apply pointer-events-auto absolute z-10 flex h-6 w-6 items-center justify-center rounded-full border border-white/20 bg-black/70 text-white opacity-0 transition;
}
.group:hover .node-add {
@apply opacity-100;
}
.node-add--top {
top: -18px;
left: 50%;
transform: translate(-50%, 0);
}
.node-add--bottom {
bottom: -18px;
left: 50%;
transform: translate(-50%, 0);
}
.node-connector {
@apply pointer-events-none absolute h-3 w-3 rounded-full border-2 border-white/60 bg-[#070d1a];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../../../utils/auth'
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../../../models/DecisionNode'
import { serializeNodeDocument } from '../../../../../../services/decisionFlowService'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
const stateParam = event.context.params?.stateId
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
if (typeof stateParam !== 'string' || !stateParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
}
const slug = slugParam.trim()
const currentStateId = stateParam.trim().toUpperCase()
const body = await readBody<{ stateId?: string }>(event)
const nextStateRaw = typeof body?.stateId === 'string' ? body.stateId.trim() : ''
if (!nextStateRaw) {
throw createError({ statusCode: 400, statusMessage: 'Neuer Identifier ist erforderlich' })
}
const nextStateId = nextStateRaw.toUpperCase()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const existingNode = await DecisionNode.findOne({ flow: flow._id, stateId: nextStateId })
if (existingNode) {
throw createError({ statusCode: 409, statusMessage: 'State identifier already exists' })
}
const node = await DecisionNode.findOne({ flow: flow._id, stateId: currentStateId })
if (!node) {
throw createError({ statusCode: 404, statusMessage: 'State not found' })
}
if (nextStateId === currentStateId) {
const references = await DecisionNode.find({
flow: flow._id,
'transitions.target': currentStateId,
})
return {
node: serializeNodeDocument(node),
references: references.map((ref) => serializeNodeDocument(ref)),
flow: {
startState: flow.startState,
endStates: flow.endStates ?? [],
},
}
}
const referencingNodes = await DecisionNode.find({
flow: flow._id,
'transitions.target': currentStateId,
}).select('_id')
const referencingIds = referencingNodes.map((entry) => entry._id)
node.stateId = nextStateId
await node.save()
if (referencingIds.length) {
await DecisionNode.updateMany(
{ flow: flow._id, _id: { $in: referencingIds } },
{ $set: { 'transitions.$[transition].target': nextStateId } },
{ arrayFilters: [{ 'transition.target': currentStateId }] }
)
}
if (flow.startState === currentStateId) {
flow.startState = nextStateId
}
if (Array.isArray(flow.endStates) && flow.endStates.length) {
flow.endStates = flow.endStates.map((entry: string) => (entry === currentStateId ? nextStateId : entry))
}
await flow.save()
const updatedReferences = referencingIds.length
? await DecisionNode.find({ _id: { $in: referencingIds } })
: []
return {
node: serializeNodeDocument(node),
references: updatedReferences.map((ref) => serializeNodeDocument(ref)),
flow: {
startState: flow.startState,
endStates: flow.endStates ?? [],
},
}
})