Files
meadows/src/components/Editor.vue
Julien Calixte 169dd043c3 feat(editor): explain refused loop-closure drags
Dragging a Flow back onto a Stock it already touches is refused (an
Information Link never targets a Stock), but that gesture is almost
always someone trying to "close the loop" by hand. The Flow's own pipe
already is the link back, so surface a self-dismissing hint saying so
instead of letting the drop die silently (F4: guide, don't nag).
2026-06-20 10:40:49 +02:00

437 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Editor (C1) — the Vue Flow canvas and chrome.
*
* The Model store is the source of truth; `nodes`/`edges` here are a derived
* projection bound one-way into Vue Flow (ADR-0002). Vue Flow owns interaction
* (pan/zoom/drag/select) on its internal copy; we translate the gestures we care
* about back into store actions:
* - drag start → take an undo restore point
* - drag stop → commit final positions
* - Delete/Backspace → remove selected nodes (Vue Flow's own delete is off, so
* it can't delete from its copy behind the Model's back)
* After each store mutation the projection recomputes and Vue Flow reconciles by
* id, so positions never fight.
*/
import { Background } from "@vue-flow/background"
import { Controls } from "@vue-flow/controls"
import {
type Connection,
ErrorCode,
isErrorOfType,
type OnConnectStartParams,
useVueFlow,
VueFlow,
type XYPosition,
} from "@vue-flow/core"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
import { useAutosave } from "@/composables/useAutosave"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples"
import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
import GlossPanel from "./GlossPanel.vue"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
import ConverterNode from "./nodes/ConverterNode.vue"
import FlowNode from "./nodes/FlowNode.vue"
import StockNode from "./nodes/StockNode.vue"
const store = useModelStore()
const graph = computed(() => project(store.model))
const nodes = computed(() => graph.value.nodes)
const edges = computed(() => graph.value.edges)
// Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
const {
onNodeDragStart,
onNodeDragStop,
onConnect,
onConnectStart,
onConnectEnd,
onError,
onNodesInitialized,
getSelectedNodes,
getSelectedEdges,
viewport,
vueFlowRef,
fitView,
} = useVueFlow("meadows")
// Fit the view after a *load* (sample/import/restore), once the freshly projected
// nodes have been measured. Vue Flow measures node dimensions a frame after they
// mount, so calling fitView straight after setModel fits to 0×0 boxes and the model
// lands jammed in the top-left; onNodesInitialized fires post-measure, so framing
// is correct. A one-shot flag keeps it scoped to loads (not every re-init).
let fitAfterInit = false
onNodesInitialized(() => {
if (!fitAfterInit) return
fitAfterInit = false
fitView({ padding: 0.2 })
})
// Restore the last document on mount and persist every change (F7). Arm the same
// post-measure fit a load uses; fitting synchronously in onRestore would frame
// against unmeasured 0×0 nodes, landing the restored model jammed top-left.
useAutosave({
onRestore: () => {
fitAfterInit = true
},
})
onNodeDragStart(() => store.beginInteraction())
onNodeDragStop(({ nodes: dragged }) => {
for (const node of dragged) store.moveNode(node.id, node.position)
})
// Vue Flow measures the pane synchronously in its own onMounted, before the
// browser has flushed first layout, so the initial read is 0×0 and it emits a
// benign MISSING_VIEWPORT_DIMENSIONS error (it falls back to 500×500, then its
// ResizeObserver corrects to the real size next frame). Swallow only that one;
// registering any handler suppresses Vue Flow's default console.warn, so log
// every other error to keep genuine problems visible.
onError((err) => {
if (isErrorOfType(err, ErrorCode.MISSING_VIEWPORT_DIMENSIONS)) return
console.warn(err.message)
})
// --- Connecting (F2) ---
// Refuse invalid drops mid-gesture so the structure stays valid (F4, "guide,
// don't nag"). The same guard backs store.connect as defence in depth.
//
// Vue Flow runs this guard for BOTH live gestures and the edges we project. The
// projected pipe/link edges arrive as full edges (with an `id`) and are valid by
// construction — the store guards every mutation — whereas a live gesture is a
// handle-less Connection. We must only guard the gesture: the derived pipe-out
// edge is valve→stock, which is invalid *as a gesture* but a real rendered edge,
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
function isValidConnection(connection: Connection): boolean {
if ("id" in connection) return true
const ok = canConnect(store.model, connection.source, connection.target)
// Remember the latest *refused* close-the-loop attempt so the release handler
// can explain it. Overwrite on every candidate (clearing when valid) so this
// reflects the handle we actually let go on, not one we merely passed over.
pendingLoopHint = ok ? null : loopClosureHint(connection.source, connection.target)
return ok
}
/**
* The message for dragging a Flow back onto a Stock it already touches — the
* "close the loop by hand" gesture. The drop is refused (an Information Link
* never targets a Stock; Stocks change only via Flows, ADR-0001), but the loop
* is already there: the Flow's own pipe *is* the link back. Naming that turns a
* dead-end gesture into the lesson (F4). Returns null for any other refusal,
* which stays silent.
*/
function loopClosureHint(sourceId: string, targetId: string): string | null {
const flow = store.model.nodes.find((node) => node.id === sourceId)
if (flow?.kind !== "flow") return null
const stock = store.model.nodes.find((node) => node.id === targetId)
if (stock?.kind !== "stock") return null
if (flow.source !== stock.id && flow.target !== stock.id) return null
const direction = flow.source === stock.id ? "out of" : "into"
return `${flow.name} already flows ${direction} ${stock.name} — that pipe is the link back, so the loop is already there.`
}
// A transient, self-dismissing teaching hint shown over the canvas (F4).
const hint = ref<string | null>(null)
let hintTimer: ReturnType<typeof setTimeout> | undefined
function showHint(message: string): void {
hint.value = message
clearTimeout(hintTimer)
hintTimer = setTimeout(() => (hint.value = null), 6000)
}
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
let connectSource: string | null = null
let connectionMade = false
// Set by isValidConnection when the live gesture is a refused loop-closure.
let pendingLoopHint: string | null = null
onConnectStart((params: OnConnectStartParams) => {
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
connectionMade = false
pendingLoopHint = null
})
onConnect((connection) => {
connectionMade = true
store.connect(connection.source, connection.target)
})
onConnectEnd((event) => {
if (!connectionMade) {
// A refused loop-closure and an empty-canvas open are mutually exclusive
// (the former needs a Flow source, the latter a Stock source), so prefer the
// explanation when we have one.
if (pendingLoopHint) showHint(pendingLoopHint)
else if (connectSource && event) {
const point = pointerPosition(event)
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
}
}
connectSource = null
pendingLoopHint = null
})
/** clientX/clientY from a mouse or touch end event. */
function pointerPosition(event: MouseEvent | TouchEvent): { x: number; y: number } | null {
if ("clientX" in event) return { x: event.clientX, y: event.clientY }
const touch = event.changedTouches[0]
return touch ? { x: touch.clientX, y: touch.clientY } : null
}
/** Convert a screen point (clientX/clientY) to graph coordinates. */
function screenToFlow(clientX: number, clientY: number): XYPosition {
const rect = vueFlowRef.value?.getBoundingClientRect()
const { x, y, zoom } = viewport.value
return {
x: (clientX - (rect?.left ?? 0) - x) / zoom,
y: (clientY - (rect?.top ?? 0) - y) / zoom,
}
}
/** Drop a node so its top-left lands roughly centred on the given graph point. */
function placeNode(kind: PlaceableKind, at: XYPosition): void {
const position = { x: at.x - 48, y: at.y - 24 }
if (kind === "stock") store.addStock(position)
else store.addConverter(position)
}
/** Click-to-add: drop at the visible pane centre, cascaded so clicks don't stack. */
function addNode(kind: PlaceableKind): void {
const rect = vueFlowRef.value?.getBoundingClientRect()
const centre = screenToFlow(
(rect?.left ?? 0) + (rect?.width ?? 800) / 2,
(rect?.top ?? 0) + (rect?.height ?? 600) / 2,
)
const step = (store.model.nodes.length % 8) * 22
placeNode(kind, { x: centre.x + step, y: centre.y + step })
}
/** Drag-to-add: drop where the chip is released. */
function onDrop(event: DragEvent): void {
event.preventDefault()
const kind = event.dataTransfer?.getData(NODE_DND_MIME)
if (kind !== "stock" && kind !== "converter") return
placeNode(kind, screenToFlow(event.clientX, event.clientY))
}
function onDragOver(event: DragEvent): void {
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
}
/**
* Load a sample Model (G4, the instructive goal). `setModel` replaces the whole
* document and clears undo history, so guard real work behind a confirm — but
* stay frictionless on the empty starting canvas. Fit the view once the
* projection has re-derived so the loaded model lands framed.
*/
function loadSample(sample: Sample): void {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
return
}
fitAfterInit = true
store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
;(document.activeElement as HTMLElement | null)?.blur()
}
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
/** A filesystem-safe stem from the model name, e.g. "Coffee cooling" → "coffee-cooling". */
function fileStem(name: string): string {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
return slug || "model"
}
/** Export the current Model as versioned JSON download (F8). */
function exportModel(): void {
const blob = new Blob([serializeModel(store.model)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = `${fileStem(store.model.name)}.json`
anchor.click()
URL.revokeObjectURL(url)
}
/** Import a Model from a JSON file: validate, confirm a destructive replace, load (F8). */
async function onImportFile(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// Reset first so picking the same file again still fires `change`.
input.value = ""
if (!file) return
const result = parseModel(await file.text())
if (!result.ok) {
window.alert(`Couldn't import “${file.name}”: ${result.error}`)
return
}
// Importing replaces the document and clears undo (T6 / F10×F7), so guard real work.
if (
store.nodeCount > 0 &&
!window.confirm(`Replace the current model with “${result.model.name}”?`)
) {
return
}
fitAfterInit = true
store.setModel(result.model)
}
function isTextEntry(target: EventTarget | null): boolean {
const el = target as HTMLElement | null
return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true
}
function onKeydown(event: KeyboardEvent): void {
if (isTextEntry(event.target)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key.toLowerCase() === "z") {
event.preventDefault()
if (event.shiftKey) store.redo()
else store.undo()
return
}
if (event.key === "Delete" || event.key === "Backspace") {
const nodeIds = getSelectedNodes.value.map((node) => node.id)
// Edges are projected, not stored: a "pipe" edge stands in for its Flow node
// (id is `${flowId}::in|out`), an "info" edge is the Information Link itself.
const flowIds = new Set<string>()
const linkIds = new Set<string>()
for (const edge of getSelectedEdges.value) {
if (edge.data?.kind === "pipe") flowIds.add(edge.id.split("::")[0])
else if (edge.data?.kind === "info") linkIds.add(edge.id)
}
if (nodeIds.length === 0 && flowIds.size === 0 && linkIds.size === 0) return
event.preventDefault()
for (const id of nodeIds) store.removeNode(id)
for (const id of flowIds) store.removeNode(id)
for (const id of linkIds) store.removeInfoLink(id)
}
}
onMounted(() => window.addEventListener("keydown", onKeydown))
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown)
clearTimeout(hintTimer)
})
</script>
<template>
<div class="flex h-screen flex-col bg-base-200">
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
<img src="/favicon.svg" alt="" class="size-6" />
<h1 class="text-lg font-semibold">meadows</h1>
<span class="text-sm text-base-content/50">
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
</span>
<div class="ml-auto flex items-center gap-1">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-10 mt-1 w-72 gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</li>
</ul>
</div>
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
<input
ref="fileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="onImportFile"
/>
<button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
Undo
</button>
<button class="btn btn-ghost btn-sm" :disabled="!store.canRedo" @click="store.redo()">
Redo
</button>
</div>
</header>
<div class="relative min-h-0 flex-1" @drop="onDrop" @dragover="onDragOver">
<VueFlow
id="meadows"
:nodes="nodes"
:edges="edges"
:delete-key-code="null"
:is-valid-connection="isValidConnection"
:connection-radius="30"
:min-zoom="0.2"
:max-zoom="4"
class="size-full"
>
<Background :gap="20" pattern-color="#d1d5db" />
<Controls position="bottom-left" :show-interactive="false" />
<template #node-stock="nodeProps">
<StockNode v-bind="nodeProps" />
</template>
<template #node-converter="nodeProps">
<ConverterNode v-bind="nodeProps" />
</template>
<template #node-flow="nodeProps">
<FlowNode v-bind="nodeProps" />
</template>
<template #node-cloud="nodeProps">
<CloudNode v-bind="nodeProps" />
</template>
<template #edge-info="edgeProps">
<InfoLinkEdge v-bind="edgeProps" />
</template>
</VueFlow>
<!-- Chrome lives at this level, above the badge overlay: Vue Flow's pane is a
z-index:0 stacking context, so a Panel inside it would be painted under
the screen-space R/B badges (which sit on the canvas, over loop centres). -->
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
<LoopOverlay />
<GlossPanel />
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
the palette and badge overlay. role=status announces it politely. -->
<div v-if="hint" class="toast toast-center toast-bottom z-30">
<div role="status" class="alert alert-info max-w-sm text-left shadow-lg">
<span>{{ hint }}</span>
<button
type="button"
class="btn btn-circle btn-ghost btn-xs"
aria-label="Dismiss"
@click="hint = null"
>
</button>
</div>
</div>
</div>
</div>
</template>