feat(editor): wire the canvas and mount the editor

This commit is contained in:
Julien Calixte
2026-06-20 09:37:50 +02:00
parent 03848ae893
commit d3119e8a20
4 changed files with 314 additions and 17 deletions

View File

@@ -1,21 +1,7 @@
<script setup lang="ts">
const appName = "meadows"
import Editor from "@/components/Editor.vue"
</script>
<template>
<main class="min-h-screen bg-base-200 flex items-center justify-center p-6">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body items-center text-center">
<img src="/favicon.svg" alt="" class="size-12" />
<h1 class="card-title text-3xl font-semibold">{{ appName }}</h1>
<p class="text-base-content/70">
A canvas for thinking in systems stocks, flows, faucets, and feedback loops.
</p>
<div class="card-actions mt-4">
<button class="btn btn-primary" disabled>Start a system</button>
</div>
<p class="text-xs text-base-content/40 mt-2">Scaffold ready. Editor coming next.</p>
</div>
</div>
</main>
<Editor />
</template>

294
src/components/Editor.vue Normal file
View File

@@ -0,0 +1,294 @@
<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,
Panel,
useVueFlow,
VueFlow,
type XYPosition,
} from "@vue-flow/core"
import { computed, nextTick, onBeforeUnmount, onMounted } from "vue"
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 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,
getSelectedNodes,
getSelectedEdges,
viewport,
vueFlowRef,
fitView,
} = useVueFlow("meadows")
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
return canConnect(store.model, connection.source, connection.target)
}
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
let connectSource: string | null = null
let connectionMade = false
onConnectStart((params: OnConnectStartParams) => {
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
connectionMade = false
})
onConnect((connection) => {
connectionMade = true
store.connect(connection.source, connection.target)
})
onConnectEnd((event) => {
if (!connectionMade && connectSource && event) {
const point = pointerPosition(event)
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
}
connectSource = 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.
*/
async function loadSample(sample: Sample): Promise<void> {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
return
}
store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
;(document.activeElement as HTMLElement | null)?.blur()
await nextTick()
fitView({ padding: 0.2 })
}
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))
</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" :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" />
<Panel position="top-left">
<Palette @add="addNode" />
</Panel>
<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>
<LoopOverlay />
</div>
</div>
</template>

View File

@@ -1,5 +1,10 @@
import { createPinia } from "pinia"
import { createApp } from "vue"
// Vue Flow styles first so our Tailwind utilities can override them.
import "@vue-flow/core/dist/style.css"
import "@vue-flow/core/dist/theme-default.css"
import "@vue-flow/controls/dist/style.css"
import "./style.css"
import App from "./App.vue"
createApp(App).mount("#app")
createApp(App).use(createPinia()).mount("#app")

View File

@@ -15,3 +15,15 @@
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
/* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The
Flow valve is small, so its handles get extra size. Loaded after Vue Flow's
own CSS (see main.ts) so these win. */
.vue-flow__handle {
width: 11px;
height: 11px;
}
.vue-flow__node-flow .vue-flow__handle {
width: 14px;
height: 14px;
}