feat(editor): wire the canvas and mount the editor
This commit is contained in:
18
src/App.vue
18
src/App.vue
@@ -1,21 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const appName = "meadows"
|
import Editor from "@/components/Editor.vue"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen bg-base-200 flex items-center justify-center p-6">
|
<Editor />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
294
src/components/Editor.vue
Normal file
294
src/components/Editor.vue
Normal 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>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { createPinia } from "pinia"
|
||||||
import { createApp } from "vue"
|
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 "./style.css"
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
|
|
||||||
createApp(App).mount("#app")
|
createApp(App).use(createPinia()).mount("#app")
|
||||||
|
|||||||
@@ -15,3 +15,15 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user