[{"data":1,"prerenderedAt":39},["ShallowReactive",2],{"4529722":3},{"type_of":4,"id":5,"title":6,"description":7,"readable_publish_date":8,"slug":9,"path":10,"url":11,"comments_count":12,"public_reactions_count":12,"collection_id":13,"published_timestamp":14,"language":15,"subforem_id":16,"ai_disclosure_level":17,"ai_disclosure_label":18,"positive_reactions_count":12,"cover_image":13,"social_image":19,"canonical_url":20,"created_at":21,"edited_at":13,"crossposted_at":13,"published_at":14,"last_comment_at":14,"reading_time_minutes":22,"tag_list":23,"tags":24,"body_html":29,"body_markdown":30,"user":31},"article",4529722,"Build a workflow editor in Vue 3 — the whole thing","Originally published on the Grafloria engineering blog.   Vue gets fewer diagram tutorials than...","Aug 30","build-a-workflow-editor-in-vue-3-the-whole-thing-4602","/ramy_othman/build-a-workflow-editor-in-vue-3-the-whole-thing-4602","https://dev.to/ramy_othman/build-a-workflow-editor-in-vue-3-the-whole-thing-4602",0,null,"2026-08-30T16:12:28Z","en",1,"not_disclosed","Not Disclosed","https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe32qjoftw3wlf9ef7yxz.png","https://grafloria.com/blog/build-a-workflow-editor-in-vue/","2026-08-30T16:04:23Z",3,"vue, javascript, tutorial, webdev",[25,26,27,28],"vue","javascript","tutorial","webdev","\u003Cblockquote>\n\u003Cp>Originally published on the \u003Ca href=\"https://grafloria.com/blog/build-a-workflow-editor-in-vue/\" target=\"_blank\" rel=\"noopener noreferrer\">Grafloria engineering blog\u003C/a>.\u003C/p>\n\u003C/blockquote>\n\n\u003Cp>Vue gets fewer diagram tutorials than React, and the ones that exist usually stop at \"nodes appear on screen\". This one goes where real workflow editors go: custom node cards, ports with connection rules, ⌘Z, auto-layout, and a save format — in Vue idiom the whole way. Everything below is running code; it's the same API our Vue demo gallery exercises in CI.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"1-a-canvas-with-taste\" href=\"#1-a-canvas-with-taste\">\n  \u003C/a>\n  1 — A canvas with taste\n\u003C/h2>\n\n\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight html\">\u003Ccode>\nimport { GrafloriaFlow } from '@grafloria/vue';\n\nconst nodes = [\n  { id: 'trigger', type: 'step', position: { x: 60, y: 120 },\n    size: { width: 220, height: 90 }, data: { title: 'New signup', kind: 'Trigger' } },\n  { id: 'email', type: 'step', position: { x: 380, y: 120 },\n    size: { width: 220, height: 90 }, data: { title: 'Send welcome email', kind: 'Action' } },\n];\nconst edges = [{ id: 'e1', source: 'trigger', target: 'email' }];\n\n          {{ data.kind }}\n          {{ data.title }}\n\n.step { height: 100%; background: #fff; border: 1.5px solid #94A5F0; border-radius: 12px;\n        padding: 10px 14px; box-sizing: border-box; }\n.kind { font-size: 11px; font-weight: 600; color: #3B52D9; text-transform: uppercase; }\n.title { font-weight: 700; }\n\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>The \u003Ccode>#node-step\u003C/code> slot renders every node of type \u003Ccode>step\u003C/code> — declaring the slot is the whole custom-node opt-in. \u003Ccode>:plugins=\"true\"\u003C/code> mounts the minimap, zoom controls and dotted background (lazy-loaded). Give the wrapper a real height: the canvas fills its container, and 100% of zero is a blank page.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"2-ports-and-the-rules-of-connection\" href=\"#2-ports-and-the-rules-of-connection\">\n  \u003C/a>\n  2 — Ports and the rules of connection\n\u003C/h2>\n\n\u003Cp>A workflow editor without connection rules teaches users to build broken flows. Ports carry direction; validators carry your domain:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight javascript\">\u003Ccode>\u003Cspan class=\"k\">import\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"nx\">registerConnectionValidator\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"nx\">clearConnectionValidators\u003C/span> \u003Cspan class=\"p\">}\u003C/span> \u003Cspan class=\"k\">from\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">@grafloria/element\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"k\">import\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"nx\">onBeforeUnmount\u003C/span> \u003Cspan class=\"p\">}\u003C/span> \u003Cspan class=\"k\">from\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">vue\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">nodes\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\n  \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">id\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">trigger\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">type\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">step\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">position\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">x\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">60\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">y\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">120\u003C/span> \u003Cspan class=\"p\">},\u003C/span> \u003Cspan class=\"na\">size\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">width\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">220\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">height\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">90\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n    \u003Cspan class=\"na\">data\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">title\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">New signup\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">kind\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">Trigger\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n    \u003Cspan class=\"na\">ports\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[{\u003C/span> \u003Cspan class=\"na\">id\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">t-out\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">side\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">right\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">type\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">output\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">}]\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n  \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">id\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">email\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">type\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">step\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">position\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">x\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">380\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">y\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">120\u003C/span> \u003Cspan class=\"p\">},\u003C/span> \u003Cspan class=\"na\">size\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">width\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">220\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">height\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">90\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n    \u003Cspan class=\"na\">data\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">title\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">Send welcome email\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">kind\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">Action\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n    \u003Cspan class=\"na\">ports\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[{\u003C/span> \u003Cspan class=\"na\">id\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">e-in\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">side\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">left\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">type\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">input\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n            \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">id\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">e-out\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">side\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">right\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">type\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">output\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">}]\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n\u003Cspan class=\"p\">];\u003C/span>\n\n\u003Cspan class=\"kd\">let\u003C/span> \u003Cspan class=\"nx\">dispose\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"kd\">function\u003C/span> \u003Cspan class=\"nf\">onInit\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">instance\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"nx\">dispose\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">registerConnectionValidator\u003C/span>\u003Cspan class=\"p\">(({\u003C/span> \u003Cspan class=\"nx\">sourceNode\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"nx\">targetNode\u003C/span> \u003Cspan class=\"p\">})\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n    \u003Cspan class=\"k\">if \u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">targetNode\u003C/span>\u003Cspan class=\"p\">?.\u003C/span>\u003Cspan class=\"nx\">getData\u003C/span>\u003Cspan class=\"p\">?.(\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">kind\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">===\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">Trigger\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">a Trigger has no inputs\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n    \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"kc\">true\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n  \u003Cspan class=\"p\">});\u003C/span>\n  \u003Cspan class=\"nx\">instance\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">getEngine\u003C/span>\u003Cspan class=\"p\">().\u003C/span>\u003Cspan class=\"nf\">setInteractionConfig\u003C/span>\u003Cspan class=\"p\">({\u003C/span> \u003Cspan class=\"na\">portVisibility\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">always\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">});\u003C/span>\n\u003Cspan class=\"p\">}\u003C/span>\n\u003Cspan class=\"nf\">onBeforeUnmount\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"nx\">dispose\u003C/span>\u003Cspan class=\"p\">?.();\u003C/span> \u003Cspan class=\"nf\">clearConnectionValidators\u003C/span>\u003Cspan class=\"p\">();\u003C/span> \u003Cspan class=\"p\">});\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Wire it with \u003Ccode>@init=\"onInit\"\u003C/code>. An \u003Ccode>input\u003C/code> port refuses to start a wire, the validator vetoes with a reason your user sees, and — the part you never have to build — \u003Cstrong>⌘Z already works\u003C/strong>: every gesture is one command on the engine's history.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"3-layout-and-persistence\" href=\"#3-layout-and-persistence\">\n  \u003C/a>\n  3 — Layout and persistence\n\u003C/h2>\n\n\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight javascript\">\u003Ccode>\u003Cspan class=\"c1\">// stop hand-placing steps: one prop\u003C/span>\n\u003Cspan class=\"c1\">//    (or \"elk\" for gnarlier graphs)\u003C/span>\n\n\u003Cspan class=\"c1\">// save — the whole diagram is one document:\u003C/span>\n\u003Cspan class=\"k\">import\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"nx\">DiagramSerializer\u003C/span> \u003Cspan class=\"p\">}\u003C/span> \u003Cspan class=\"k\">from\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">@grafloria/element\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">json\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nx\">JSON\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">stringify\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"k\">new\u003C/span> \u003Cspan class=\"nc\">DiagramSerializer\u003C/span>\u003Cspan class=\"p\">().\u003C/span>\u003Cspan class=\"nf\">serialize\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">instance\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">getModel\u003C/span>\u003Cspan class=\"p\">()));\u003C/span>\n\n\u003Cspan class=\"c1\">// text for humans: the same flow as valid Mermaid, positions preserved\u003C/span>\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">text\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nx\">instance\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">exportText\u003C/span>\u003Cspan class=\"p\">();\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Ch2>\n  \u003Ca name=\"where-to-go-from-here\" href=\"#where-to-go-from-here\">\n  \u003C/a>\n  Where to go from here\n\u003C/h2>\n\n\u003Cp>The \u003Ca href=\"https://grafloria.com/learn/vue/\" target=\"_blank\" rel=\"noopener noreferrer\">10-minute Vue tutorial\u003C/a> covers the same ground interactively; the \u003Ca href=\"https://grafloria.com/learn/vue-custom-nodes/\" target=\"_blank\" rel=\"noopener noreferrer\">Vue deep guides\u003C/a> go into slots, state and dashboards; and every demo in the gallery exists as \u003Ca href=\"https://grafloria.com/demos-vue/\" target=\"_blank\" rel=\"noopener noreferrer\">a real Vue SFC with source shown\u003C/a>. If you're coming from Vue Flow, the \u003Ca href=\"https://grafloria.com/compare/#diagrams\" target=\"_blank\" rel=\"noopener noreferrer\">sourced comparison\u003C/a> shows exactly where the boxes differ.\u003C/p>\n\n\n\u003Chr>\n\n\u003Cp>\u003Cem>Grafloria is an MIT-licensed diagram engine for React, Angular, Vue and plain JavaScript — \u003Ca href=\"https://grafloria.com/\" target=\"_blank\" rel=\"noopener noreferrer\">grafloria.com\u003C/a>. If this post was useful, the \u003Ca href=\"https://grafloria.com/demos/\" target=\"_blank\" rel=\"noopener noreferrer\">demo gallery\u003C/a> is where the ideas live as running code.\u003C/em>\u003C/p>\n\n","> Originally published on the [Grafloria engineering blog](https://grafloria.com/blog/build-a-workflow-editor-in-vue/).\n\nVue gets fewer diagram tutorials than React, and the ones that exist usually stop at \"nodes appear on screen\". This one goes where real workflow editors go: custom node cards, ports with connection rules, ⌘Z, auto-layout, and a save format — in Vue idiom the whole way. Everything below is running code; it's the same API our Vue demo gallery exercises in CI.\n\n## 1 — A canvas with taste\n\n```html\n\nimport { GrafloriaFlow } from '@grafloria/vue';\n\nconst nodes = [\n  { id: 'trigger', type: 'step', position: { x: 60, y: 120 },\n    size: { width: 220, height: 90 }, data: { title: 'New signup', kind: 'Trigger' } },\n  { id: 'email', type: 'step', position: { x: 380, y: 120 },\n    size: { width: 220, height: 90 }, data: { title: 'Send welcome email', kind: 'Action' } },\n];\nconst edges = [{ id: 'e1', source: 'trigger', target: 'email' }];\n\n          {{ data.kind }}\n          {{ data.title }}\n\n.step { height: 100%; background: #fff; border: 1.5px solid #94A5F0; border-radius: 12px;\n        padding: 10px 14px; box-sizing: border-box; }\n.kind { font-size: 11px; font-weight: 600; color: #3B52D9; text-transform: uppercase; }\n.title { font-weight: 700; }\n\n```\n\nThe `#node-step` slot renders every node of type `step` — declaring the slot is the whole custom-node opt-in. `:plugins=\"true\"` mounts the minimap, zoom controls and dotted background (lazy-loaded). Give the wrapper a real height: the canvas fills its container, and 100% of zero is a blank page.\n\n## 2 — Ports and the rules of connection\n\nA workflow editor without connection rules teaches users to build broken flows. Ports carry direction; validators carry your domain:\n\n```javascript\nimport { registerConnectionValidator, clearConnectionValidators } from '@grafloria/element';\nimport { onBeforeUnmount } from 'vue';\n\nconst nodes = [\n  { id: 'trigger', type: 'step', position: { x: 60, y: 120 }, size: { width: 220, height: 90 },\n    data: { title: 'New signup', kind: 'Trigger' },\n    ports: [{ id: 't-out', side: 'right', type: 'output' }] },\n  { id: 'email', type: 'step', position: { x: 380, y: 120 }, size: { width: 220, height: 90 },\n    data: { title: 'Send welcome email', kind: 'Action' },\n    ports: [{ id: 'e-in', side: 'left', type: 'input' },\n            { id: 'e-out', side: 'right', type: 'output' }] },\n];\n\nlet dispose;\nfunction onInit(instance) {\n  dispose = registerConnectionValidator(({ sourceNode, targetNode }) => {\n    if (targetNode?.getData?.('kind') === 'Trigger') return 'a Trigger has no inputs';\n    return true;\n  });\n  instance.getEngine().setInteractionConfig({ portVisibility: 'always' });\n}\nonBeforeUnmount(() => { dispose?.(); clearConnectionValidators(); });\n```\n\nWire it with `@init=\"onInit\"`. An `input` port refuses to start a wire, the validator vetoes with a reason your user sees, and — the part you never have to build — **⌘Z already works**: every gesture is one command on the engine's history.\n\n## 3 — Layout and persistence\n\n```javascript\n// stop hand-placing steps: one prop\n//    (or \"elk\" for gnarlier graphs)\n\n// save — the whole diagram is one document:\nimport { DiagramSerializer } from '@grafloria/element';\nconst json = JSON.stringify(new DiagramSerializer().serialize(instance.getModel()));\n\n// text for humans: the same flow as valid Mermaid, positions preserved\nconst text = instance.exportText();\n```\n\n## Where to go from here\n\nThe [10-minute Vue tutorial](https://grafloria.com/learn/vue/) covers the same ground interactively; the [Vue deep guides](https://grafloria.com/learn/vue-custom-nodes/) go into slots, state and dashboards; and every demo in the gallery exists as [a real Vue SFC with source shown](https://grafloria.com/demos-vue/). If you're coming from Vue Flow, the [sourced comparison](https://grafloria.com/compare/#diagrams) shows exactly where the boxes differ.\n\n---\n\n*Grafloria is an MIT-licensed diagram engine for React, Angular, Vue and plain JavaScript — [grafloria.com](https://grafloria.com/). If this post was useful, the [demo gallery](https://grafloria.com/demos/) is where the ideas live as running code.*",{"name":32,"username":33,"twitter_username":13,"github_username":34,"user_id":35,"website_url":36,"profile_image":37,"profile_image_90":38},"Ramy Othman","ramy_othman","visionarerthm",4101566,"https://grafloria.com","https://media2.dev.to/dynamic/image/width=640,height=640,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4101566%2F943518a4-ccb8-4ccb-bad0-f86d168e24f8.png","https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4101566%2F943518a4-ccb8-4ccb-bad0-f86d168e24f8.png",1788519848652]