@thomasfosterau/prosekit-flowchart 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Foster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,181 @@
1
1
  # @thomasfosterau/prosekit-flowchart
2
2
 
3
- Placeholder reserved for npm trusted publishing; the first real release is 0.1.0, from https://github.com/thomasfosterau/prosekit-utilities.
3
+ A flowchart block for [ProseKit](https://prosekit.dev) documents, in two entry points. `./node` is the canvas-free half — the ProseMirror node spec and its `updateFlowchartData` command, the [Effect Schema](https://effect.website) payload, a server-renderable SVG, the attribute-override entry, and the HTML / Markdown serialiser hooks — and imports neither Svelte nor `@xyflow/svelte`, so it is safe on a server and in any bundle that never renders the interactive canvas. The root is the `@xyflow/svelte` canvas (`Flowchart`), the edit dialog, the editable node, the ProseKit Svelte node view, and the toolbar / insert-menu entries.
4
+
5
+ **Why it is its own package.** A component whose behaviour needs a specific third-party dependency lives in its own package adjacent to the editor package, so the editor package stays free of it. Here that dependency is `@xyflow/svelte` — installing this package is the decision to render a flowchart, it is this package's **required** peer, and `@thomasfosterau/prosekit-svelte` never mentions it.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pnpm add @thomasfosterau/prosekit-flowchart @xyflow/svelte
11
+ ```
12
+
13
+ ### Peer requirements
14
+
15
+ | Package | Range | Why |
16
+ | --- | --- | --- |
17
+ | `@xyflow/svelte` | `^1.6.6`, **required** | The canvas engine, and this package's reason to exist as a package. Reached from exactly one module (`Flowchart.svelte`), which `FlowchartEditDialog` loads through a dynamic `import()` the first time it opens. |
18
+ | `svelte` | `^5.30.1` | The components are compiled by your build, not pre-compiled. |
19
+ | `prosekit` | `^0.22.2` | `defineFlowchart()` registers a node spec on a ProseKit extension; `defineFlowchartNodeView()` is one. |
20
+ | `@prosekit/pm` | `^0.1.19` | The node view's `Node` / `Attrs` types. |
21
+ | `effect` | `^4.0.0` | The payload schemas under `./node`. The root entry imports no Effect itself. |
22
+
23
+ `@thomasfosterau/prosekit-utilities` and `@thomasfosterau/prosekit-svelte` are not peers — they are ordinary dependencies of this package, at the same release line, and come along automatically when you install it. `bits-ui` does too, for `FlowchartEditDialog`'s `Dialog`.
24
+
25
+ Tailwind and daisyUI are not declared peers, but they are **required**: every component here emits daisyUI classes (`btn`, `modal-box`, `input`, `text-error`) and ships no CSS of its own. Add this package's `dist/` to Tailwind's sources, or its classes are never emitted:
26
+
27
+ ```css
28
+ @import 'tailwindcss';
29
+ @source '../node_modules/@thomasfosterau/prosekit-svelte/dist';
30
+ @source '../node_modules/@thomasfosterau/prosekit-flowchart/dist';
31
+ @plugin "daisyui";
32
+ ```
33
+
34
+ `@xyflow/svelte/dist/style.css` is imported by `Flowchart.svelte` for its side effect — you do not import it yourself, and it does not arrive on a page that never opens the edit dialog.
35
+
36
+ ## Wiring the block into an editor
37
+
38
+ `./node` gives an application the grammar; the root gives it the UI. A worked example, threading both through one `blockDocument` surface:
39
+
40
+ ```ts
41
+ // document-schema.ts — server-only: the grammar, and the derived wire schema
42
+ import {
43
+ defineBlockDocumentExtension,
44
+ defineDocumentSchema,
45
+ mergeAttributeOverrides,
46
+ STANDARD_ATTRIBUTE_OVERRIDES,
47
+ } from '@thomasfosterau/prosekit-utilities/prosekit';
48
+ import {
49
+ defineFlowchart,
50
+ FLOWCHART_ATTRIBUTE_OVERRIDES,
51
+ } from '@thomasfosterau/prosekit-flowchart/node';
52
+ import { blockDocument } from './document-shapes.ts';
53
+
54
+ export const blockDocumentSchema = defineDocumentSchema(blockDocument, {
55
+ extension: () => defineBlockDocumentExtension(defineFlowchart()),
56
+ attributes: mergeAttributeOverrides(STANDARD_ATTRIBUTE_OVERRIDES, FLOWCHART_ATTRIBUTE_OVERRIDES),
57
+ });
58
+ ```
59
+
60
+ ```ts
61
+ // posts.server.ts — HTML rendering, with the flowchart placeholder filled in
62
+ import { createHtmlRenderer } from '@thomasfosterau/prosekit-utilities/prosekit';
63
+ import { postProcessFlowcharts } from '@thomasfosterau/prosekit-flowchart/node';
64
+ import { blockDocumentSchema } from './document-schema.ts';
65
+
66
+ export const blockRenderer = createHtmlRenderer({
67
+ schema: blockDocumentSchema.schema,
68
+ postProcess: postProcessFlowcharts,
69
+ });
70
+ ```
71
+
72
+ ```ts
73
+ // markdown.ts — the Markdown serialiser, with the flowchart placeholder text
74
+ import { createMarkdownSerialiser } from '@thomasfosterau/prosekit-utilities';
75
+ import { flowchartMarkdown } from '@thomasfosterau/prosekit-flowchart/node';
76
+
77
+ export const toMarkdown = createMarkdownSerialiser({
78
+ blocks: { flowchart: flowchartMarkdown() },
79
+ });
80
+ ```
81
+
82
+ ```svelte
83
+ <!-- Editor.svelte — the editor route: schema half + UI half, composed once -->
84
+ <script lang="ts">
85
+ import {
86
+ BlockEditor,
87
+ createDefaultSlashCommands,
88
+ createDefaultToolbarItems,
89
+ } from '@thomasfosterau/prosekit-svelte';
90
+ import { defineBlockDocumentExtension } from '@thomasfosterau/prosekit-utilities/prosekit';
91
+ import { defineFlowchart } from '@thomasfosterau/prosekit-flowchart/node';
92
+ import {
93
+ defineFlowchartNodeView,
94
+ flowchartSlashCommand,
95
+ flowchartToolbarItem,
96
+ } from '@thomasfosterau/prosekit-flowchart';
97
+ import { union } from 'prosekit/core';
98
+
99
+ const appExtension = defineBlockDocumentExtension(defineFlowchart());
100
+
101
+ // The schema half and the view half, composed once.
102
+ const extension = union(appExtension, defineFlowchartNodeView());
103
+
104
+ const items = [...createDefaultToolbarItems(), flowchartToolbarItem()];
105
+ const slashCommands = [...createDefaultSlashCommands(), flowchartSlashCommand()];
106
+
107
+ let content = $state();
108
+ </script>
109
+
110
+ <BlockEditor {extension} {items} {slashCommands} bind:content />
111
+ ```
112
+
113
+ Server-side, an application keeps only `./node`: `defineFlowchart()` in the extension its document schema derives from, `FLOWCHART_ATTRIBUTE_OVERRIDES` in its attribute overlay, `postProcessFlowcharts` in its HTML renderer, and `flowchartMarkdown()` in its Markdown serialiser. Nothing on that path imports Svelte or `@xyflow/svelte`.
114
+
115
+ ---
116
+
117
+ # `./node`
118
+
119
+ The flowchart block without a canvas. Imports no Svelte and no `@xyflow/svelte`, so it is safe on a server, in a Worker, and in any bundle that never renders the interactive canvas; the root entry is what pulls those in.
120
+
121
+ ```ts
122
+ import { defineFlowchart } from '@thomasfosterau/prosekit-flowchart/node';
123
+ ```
124
+
125
+ | Export | Signature | Notes |
126
+ | --- | --- | --- |
127
+ | `FlowchartNode`, `FlowchartEdge`, `FlowchartData` | Effect Schemas (each also the decoded type of the same name): `Schema.Struct({ id, position: { x, y }, data: { label } })`, `Schema.Struct({ id, source, target })`, `Schema.Struct({ nodes: Schema.Array(FlowchartNode), edges: Schema.Array(FlowchartEdge) })` | The persisted shape — a strict subset of what the canvas's own dependency (Svelte Flow) accepts, so its internal fields (selection, measured dimensions) never round-trip. |
128
+ | `FlowchartPosition` | `type FlowchartPosition = FlowchartNode['position']` | Derived, not a separate schema. |
129
+ | `EMPTY_FLOWCHART` | `FlowchartData` (`{ nodes: [], edges: [] }`) | The node spec's attribute default and `parseFlowchartData`'s failure value. |
130
+ | `parseFlowchartData` | `(input: string) => FlowchartData` | Total, never throws. Invalid JSON or a non-object JSON value both yield a fresh `{ ...EMPTY_FLOWCHART }`; missing or non-array `nodes` / `edges` default to `[]`; each node and edge is decoded **individually**, so one malformed entry is dropped rather than failing the whole array. |
131
+ | `generateFlowchartSvg`, `FlowchartSvgLabels` | `(data: FlowchartData, labels?: FlowchartSvgLabels) => string`; `interface FlowchartSvgLabels { emptyLabel?: string \| undefined; diagramLabel?: ((nodeCount: number) => string) \| undefined; untitledLabel?: string \| undefined }` | The three hard-coded strings ("Empty flowchart", `Flowchart diagram with ${n} node(s)`, "Untitled") are individually overridable through `labels`, and each still defaults to that exact copy. Uses `currentColor` strokes and text, `width="100%"`, a viewBox derived from persisted positions plus 20px padding; a label's XML-sensitive characters are escaped. |
132
+ | `defineFlowchart` | `() => Extension` | Registers the `flowchart` node spec (`group: 'block'`, `atom: true`, `isolating: true`, `draggable: true`, `selectable: true`) and the `updateFlowchartData(pos, data)` command. `toDOM` emits `<div data-flowchart data-payload="…">` with the JSON unicode-escaped (`<`, `>`, `&`, `'` → `\uXXXX`) so a label can never break out of the attribute; `parseDOM` prefers `data-payload`, falling back to the element's `textContent` for older documents, then to `parseFlowchartData`'s empty-payload default for anything unparseable. `updateFlowchartData` is a no-op (returns `false`, no dispatch) when the target position is not a `flowchart` node. |
133
+ | `FLOWCHART_ATTRIBUTE_OVERRIDES` | `Readonly<Record<string, Readonly<Record<string, Schema.Top \| RequiredAttribute>>>>` — `{ flowchart: { data: required(FlowchartData) } }` | The overlay entry a consuming app merges into its own attribute overlay (see [Wiring the block into an editor](#wiring-the-block-into-an-editor)). Wrapped in `required(...)`: a live editor's `flowchart` node always serialises `data` (`Node.toJSON()` includes every attribute, defaulted or not), so a machine-authored document must supply it too. |
134
+ | `postProcessFlowcharts` | `(html: string, document: Document) => string` | The `createHtmlRenderer({ postProcess })` hook: replaces every `[data-flowchart]` placeholder's inner HTML with `generateFlowchartSvg`'s output, keeping `data-payload` so the editor can still round-trip the diagram. Short-circuits (returns `html` unchanged) when the string contains no `data-flowchart` at all. Takes the caller's `Document` implementation rather than constructing one, so it works from both a browser bundle and a server-side DOM (linkedom, …). |
135
+ | `flowchartMarkdown` | `(label?: string) => BlockSerialiser` | Default label `'_[flowchart diagram]_'`. A **factory**, unlike this workspace's other opt-in serialisers (`mentionMarkdown`, `figureMarkdown`, `sidenoteMarkdown`), so a consuming app can override the placeholder text without reimplementing the `BlockSerialiser`. Ignores the node's own content — nothing in a `FlowchartData` payload has a faithful Markdown form. |
136
+
137
+ ---
138
+
139
+ # root
140
+
141
+ The `@xyflow/svelte` canvas, the edit dialog, the editable node, the ProseKit node view and the toolbar / insert-menu entries. Everything is exported from the package root:
142
+
143
+ ```ts
144
+ import {
145
+ EditableNode,
146
+ Flowchart,
147
+ FlowchartEditDialog,
148
+ FlowchartNodeView,
149
+ defineFlowchartNodeView,
150
+ flowchartSlashCommand,
151
+ flowchartToolbarItem,
152
+ } from '@thomasfosterau/prosekit-flowchart';
153
+ ```
154
+
155
+ | Component | Purpose | Props | Snippets | ARIA |
156
+ | --- | --- | --- | --- | --- |
157
+ | `Flowchart` | The canvas itself. `interactive={false}` (the default) is a read-only render — no panning, no connecting, nodes immovable, no `Controls`; `interactive={true}` is the full WYSIWYG surface. There is deliberately no `onchange`: the parent pulls the diagram with `getSnapshot()` on save, which keeps the data flow one-way. | `data: FlowchartData` (seeds the canvas once — use `resetTo()` to replace it), `interactive?: boolean \| undefined` (default `false`), `class?: string \| undefined` (default `'h-96'`, merged after `flowchart-canvas`), `summaryLabel?: ((nodes: number, edges: number) => string) \| undefined`, `newNodeLabel?: string \| undefined` (default `'New'`), `nodeLabelLabel?: string \| undefined` (default `'Node label'`), `untitledLabel?: string \| undefined` (default `'Untitled'`), plus native `<div>` attributes (`class` excepted, spread on the container) | — | Server render (`typeof window === 'undefined'`) emits a `<p>` naming `summaryLabel(nodeCount, edgeCount)` in place of the canvas, which hydration replaces; once hydrated, `@xyflow/svelte`'s own accessibility surface applies. |
158
+ | `EditableNode` | The Svelte Flow node type `Flowchart` registers as `editable`. Double-click, or Enter while the node has focus, opens the label field; Enter commits, Escape discards, and a blank label commits as `untitledLabel`. | `NodeProps` narrowed to `id`, `data: EditableNodeData`, `selected` — Svelte Flow instantiates it from the `nodeTypes` map, so there is no call site to pass a prop at. Its copy rides in `data.labels.nodeLabelLabel` / `data.labels.untitledLabel` instead, which `Flowchart` fills from its own same-named props (`EditableNodeData` / `EditableNodeLabels` are this `.svelte` file's own module exports, not re-exported from the package root). | — | `role="button"`; in the tab order (`tabindex="0"`) only while editable (`data.readOnly !== true`) — a reader tabbing through a published diagram is not stopped at every box. The edit field is named by `nodeLabelLabel`. |
159
+ | `FlowchartEditDialog` | The modal that hosts the full WYSIWYG canvas, on bits-ui's `Dialog`. The one behaviour that is this component's own: while `onSave` is in flight, both buttons are disabled and Escape is refused, so a save is never abandoned halfway. | `open?: boolean \| undefined` (bindable, default `false`), `initialData?: FlowchartData \| undefined` (default `EMPTY_FLOWCHART`), `onSave: (data: FlowchartData) => void \| Promise<void>` (may reject), `titleLabel?: string \| undefined` (default `'Edit flowchart'`), `addNodeLabel?: string \| undefined` (default `'Add node'`), `helpText?: string \| undefined`, `saveLabel?` / `savingLabel?: string \| undefined` (default `'Save'` / `'Saving…'`), `cancelLabel?: string \| undefined` (default `'Cancel'`), `closeLabel?: string \| undefined` (default `'Close'`), `failureMessage?: string \| undefined` (shown when `onSave` rejects with something that is not an `Error`), `newNodeLabel?`, `nodeLabelLabel?`, `untitledLabel?: string \| undefined` (forwarded to the hosted `Flowchart`), `class?: string \| undefined` (merged onto the `modal-box`), plus native `<div>` attributes (spread on the `role="dialog"` element, while open; `role`, `aria-modal`, `aria-labelledby` and `aria-describedby` stay bits-ui's) | — | bits-ui's `Dialog` owns the focus trap, `aria-modal`, the `aria-labelledby` / `aria-describedby` wiring to `Dialog.Title` / `Dialog.Description`, the scroll lock and the focus return. The failure message renders in a `role="alert"`. |
160
+ | `FlowchartNodeView` | What ProseKit mounts in place of a `flowchart` node. Its preview is the **same** server-renderable SVG a reader page gets (`generateFlowchartSvg`, from `./node`), so the editor and the published article cannot drift — and so the `@xyflow/svelte` bundle only arrives once the author presses Edit. | `node: Writable<ProseMirrorNode>` (ProseKit's node store), `selected: Writable<boolean>` (ProseKit's selection store), `setAttrs: (attrs: Attrs) => void` (the save channel — Save writes `{ data }` back through it), `editLabel?: string \| undefined` (default `'Edit'`) | — | The wrapper is `contenteditable="false"` and carries `data-prosemirror-node-name="flowchart"`; its border reflects `$selected`. The Edit button is named by `editLabel`. |
161
+
162
+ Instance methods on `Flowchart`, reached through `bind:this`:
163
+
164
+ | Method | Signature | What it does |
165
+ | --- | --- | --- |
166
+ | `resetTo` | `(payload: FlowchartData) => void` | Replaces the canvas state. Call it once the canvas has a layout box, so `fitView` has something to fit — `FlowchartEditDialog` calls it inside a `queueMicrotask` after the dialog's content mounts. |
167
+ | `getSnapshot` | `() => FlowchartData` | The persisted shape, with `@xyflow/svelte`'s internal fields (selection, measured dimensions) stripped. |
168
+ | `addNode` | `(label?: string) => void` | Appends a node at a staggered position (diagonally offset by 30px per existing node), so repeated presses do not pile up. Defaults to `newNodeLabel`. |
169
+
170
+ `FlowchartEditDialog` also exposes an imperative `open(data?: FlowchartData): void`, reached the same way — the entry point `FlowchartNodeView` uses, since it has an instance to reach through `bind:this` but no place to thread a bound prop.
171
+
172
+ `defineFlowchartNodeView(): Extension` registers `FlowchartNodeView` under the node name `flowchart`, hosted in a `div`, with `ignoreMutation` and `stopEvent` both returning `true` — the diagram rewrites its own DOM (the SVG preview, and the canvas while the dialog is open), and without those guards ProseMirror would read the rewrites as document edits.
173
+
174
+ The toolbar and insert-menu entries:
175
+
176
+ | Export | Signature | Notes |
177
+ | --- | --- | --- |
178
+ | `flowchartToolbarItem` | `(label?: string) => ToolbarItem` | A toolbar button (`kind: 'action'` — inserting a block has no pressed state) that inserts a `flowchart` node and returns focus to the document. Default label `'Flowchart'`. |
179
+ | `flowchartSlashCommand` | `(label?: string) => SlashCommand` | The same insertion, as an insert-menu entry. Default label `'Flowchart'`. |
180
+
181
+ Both insert the starter diagram — one node labelled `'Start'` at `(120, 80)` — rather than an empty payload, because an empty canvas gives an author nothing to double-click and no anchor to drag a connection from. Each insertion builds its own diagram object, so two blocks never share one payload. The insertion is reached by name through the editor's own `commands`, so an entry added to a toolbar whose extension never registered `defineFlowchart()` is inert rather than throwing.
@@ -0,0 +1,111 @@
1
+ <!-- @component The Svelte Flow node type `Flowchart` renders, with an inline-editable label. Svelte Flow instantiates it, so its copy arrives through `data.labels`. -->
2
+
3
+ <!--
4
+ EditableNode — the Svelte Flow node type this package's canvas renders, with
5
+ an inline-editable label.
6
+
7
+ Double-click, or Enter while the node has focus, switches to edit mode; Enter
8
+ or blur commits the new label through `useSvelteFlow().updateNodeData(id, {
9
+ label })`; Escape restores the label the node had. In read-only mode every
10
+ edit handler is a no-op and the label renders as static text.
11
+
12
+ ## Why its strings arrive through `data`
13
+
14
+ Every other component in this workspace takes its user-facing copy as props
15
+ with English defaults. This one cannot: Svelte Flow instantiates a node
16
+ component itself, from the `nodeTypes` map, and passes it exactly the
17
+ `NodeProps` it constructs — a caller has no call site to pass a prop at. So
18
+ the two strings ride in the node's own `data` (`data.labels`), which
19
+ `Flowchart` fills from ITS props, and the defaults are applied here. The
20
+ contract is the same (every string overridable, English default); only the
21
+ channel differs.
22
+
23
+ ## ARIA contract
24
+
25
+ The node is `role="button"` — its whole purpose is "activate me to edit" —
26
+ and is in the tab order only when it is editable (`tabindex=-1` when
27
+ read-only, so a reader tabbing through a published diagram is not stopped at
28
+ every box). The edit field is named by `nodeLabelLabel`, because a bare
29
+ `<input>` inside a node would otherwise be announced as nothing at all.
30
+ -->
31
+ <script module lang="ts">export {};
32
+ </script>
33
+
34
+ <script lang="ts">import { Handle, Position, useSvelteFlow } from "@xyflow/svelte";
35
+ let { id, data, selected } = $props();
36
+ const flow = useSvelteFlow();
37
+ const nodeLabelLabel = $derived(data.labels?.nodeLabelLabel ?? "Node label");
38
+ const untitledLabel = $derived(data.labels?.untitledLabel ?? "Untitled");
39
+ let editing = $state(false);
40
+ // `draft` only matters while `editing` is true; `startEdit` seeds it from the
41
+ // current `data.label` at that moment.
42
+ let draft = $state("");
43
+ // A `bind:this` target, so the declared type must literally be `T |
44
+ // undefined`: Svelte's compiler assigns the element itself into the variable,
45
+ // and `undefined` before mount and after unmount.
46
+ let inputRef = $state();
47
+ function startEdit() {
48
+ if (data.readOnly === true) return;
49
+ draft = data.label;
50
+ editing = true;
51
+ queueMicrotask(() => inputRef?.focus());
52
+ }
53
+ function commit() {
54
+ const trimmed = draft.trim() || untitledLabel;
55
+ flow.updateNodeData(id, { label: trimmed });
56
+ editing = false;
57
+ }
58
+ function cancel() {
59
+ draft = data.label;
60
+ editing = false;
61
+ }
62
+ function handleKeyDown(event) {
63
+ if (event.key !== "Enter" && event.key !== "Escape") return;
64
+ event.preventDefault();
65
+ // `stopPropagation` is load-bearing, not tidiness. Both handlers close edit
66
+ // mode synchronously, so without it the same Enter keystroke bubbles to the
67
+ // node wrapper below, finds `editing` already false, and immediately
68
+ // reopens the field the user just committed. The browser suite catches it;
69
+ // no type would.
70
+ event.stopPropagation();
71
+ if (event.key === "Enter") commit();
72
+ else cancel();
73
+ }
74
+ function handleNodeKeyDown(event) {
75
+ if (data.readOnly !== true && event.key === "Enter" && !editing) {
76
+ event.preventDefault();
77
+ startEdit();
78
+ }
79
+ }
80
+ </script>
81
+
82
+ <div
83
+ class="flowchart-node bg-base-100 border-base-300 min-w-32 rounded border px-3 py-2 text-sm shadow-sm"
84
+ class:border-primary={selected}
85
+ class:cursor-text={data.readOnly !== true}
86
+ ondblclick={startEdit}
87
+ onkeydown={handleNodeKeyDown}
88
+ role="button"
89
+ tabindex={data.readOnly === true ? -1 : 0}
90
+ >
91
+ <Handle type="target" position={Position.Top} isConnectable={data.readOnly !== true} />
92
+ {#if editing}
93
+ <input
94
+ bind:this={inputRef}
95
+ bind:value={draft}
96
+ onkeydown={handleKeyDown}
97
+ onblur={commit}
98
+ class="input input-xs w-full"
99
+ aria-label={nodeLabelLabel}
100
+ />
101
+ {:else}
102
+ <span>{data.label || untitledLabel}</span>
103
+ {/if}
104
+ <Handle type="source" position={Position.Bottom} isConnectable={data.readOnly !== true} />
105
+ </div>
106
+
107
+ <style>
108
+ .flowchart-node {
109
+ box-sizing: border-box;
110
+ }
111
+ </style>
@@ -0,0 +1,35 @@
1
+ /** Overridable copy for {@link EditableNode}, carried in the node's `data`. */
2
+ export interface EditableNodeLabels {
3
+ /** Accessible name of the label edit field. Default: `'Node label'`. */
4
+ readonly nodeLabelLabel?: string | undefined;
5
+ /** Shown, and committed, when a label is blank. Default: `'Untitled'`. */
6
+ readonly untitledLabel?: string | undefined;
7
+ }
8
+ /**
9
+ * The `data` payload {@link EditableNode} expects — the persisted label plus
10
+ * the two canvas-local fields `Flowchart` derives from its own props.
11
+ *
12
+ * A `type` rather than an `interface`, deliberately: Svelte Flow's `Node`
13
+ * constrains its data to `Record<string, unknown>`, and only a type alias
14
+ * gets the implicit index signature that satisfies it.
15
+ */
16
+ export type EditableNodeData = {
17
+ /** The node's label, as persisted in the flowchart payload. */
18
+ label: string;
19
+ /** Set by `Flowchart` from its `interactive` prop; suppresses every edit affordance. */
20
+ readOnly?: boolean | undefined;
21
+ /** Copy overrides, passed down from `Flowchart`'s props. */
22
+ labels?: EditableNodeLabels | undefined;
23
+ };
24
+ import { type NodeProps } from '@xyflow/svelte';
25
+ /**
26
+ * Svelte Flow's own node props, narrowed to the `data` shape this node type
27
+ * reads. `id`, `selected` and `data` are the only three it uses.
28
+ */
29
+ type Props = NodeProps & {
30
+ data: EditableNodeData;
31
+ };
32
+ /** The Svelte Flow node type `Flowchart` renders, with an inline-editable label. Svelte Flow instantiates it, so its copy arrives through `data.labels`. */
33
+ declare const EditableNode: import("svelte").Component<Props, {}, "">;
34
+ type EditableNode = ReturnType<typeof EditableNode>;
35
+ export default EditableNode;
@@ -0,0 +1,170 @@
1
+ <!-- @component The `@xyflow/svelte` canvas for a `FlowchartData` payload: read-only by default, editable with `interactive`; pull the result with `getSnapshot()`. -->
2
+
3
+ <!--
4
+ Flowchart — the `@xyflow/svelte` canvas that renders a `FlowchartData`
5
+ payload.
6
+
7
+ This is the ONE module in the package that imports `@xyflow/svelte`, and with
8
+ it the package's only side-effecting import,
9
+ `@xyflow/svelte/dist/style.css`. Everything else — the node spec, the
10
+ payload schema, the server-renderable SVG the node view previews with —
11
+ lives under `./node`, which imports neither Svelte nor xyflow. That split is
12
+ what keeps the canvas bundle off every page that merely displays a diagram.
13
+
14
+ Two modes:
15
+ - `interactive={false}` (the default): a read-only render. No panning, no
16
+ connecting, nodes immovable, no `Controls`.
17
+ - `interactive={true}`: the full WYSIWYG canvas. `addNode(label)` appends a
18
+ node; drag from a node's bottom handle to another's top to connect;
19
+ double-click a node to edit its label; Delete removes the selection.
20
+
21
+ ## Why there is no `onchange`
22
+
23
+ The parent reads the current diagram with `getSnapshot()` rather than
24
+ receiving reactive change events. That keeps the data flow strictly one-way
25
+ — payload in as a prop, snapshot pulled out on save — and avoids a reactive
26
+ bounce between the parent's draft state and xyflow's per-frame internal
27
+ mutations. `resetTo(payload)` is the other direction, and the dialog calls it
28
+ once the canvas has a real layout box for `fitView` to work against.
29
+
30
+ Svelte Flow depends on DOM measurement, so the canvas is gated on a `window`
31
+ check; a server render emits the `summaryLabel` paragraph instead, which
32
+ hydration replaces.
33
+ -->
34
+ <script lang="ts">import { Background, Controls, SvelteFlow } from "@xyflow/svelte";
35
+ import "@xyflow/svelte/dist/style.css";
36
+ import { untrack } from "svelte";
37
+ import EditableNode from "./EditableNode.svelte";
38
+ // A library package cannot import SvelteKit's `$app/environment`; this is its
39
+ // portable equivalent (Node SSR and Cloudflare Workers never define `window`,
40
+ // browsers always do).
41
+ const browser = typeof window !== "undefined";
42
+ let { data, interactive = false, class: className = "h-96", summaryLabel = (nodeCount, edgeCount) => `Flowchart with ${nodeCount} node(s) and ${edgeCount} edge(s)`, newNodeLabel = "New", nodeLabelLabel = "Node label", untitledLabel = "Untitled", ...rest } = $props();
43
+ /**
44
+ * The copy `EditableNode` reads out of each node's `data`. Svelte Flow
45
+ * instantiates that component itself, so this is the only channel its two
46
+ * strings can travel down — see its header comment.
47
+ */
48
+ const labels = $derived({
49
+ nodeLabelLabel,
50
+ untitledLabel
51
+ });
52
+ function nodeData(label) {
53
+ return {
54
+ label,
55
+ readOnly: !interactive,
56
+ labels
57
+ };
58
+ }
59
+ function toFlowNodes(payload) {
60
+ return payload.nodes.map((node) => ({
61
+ id: node.id,
62
+ type: "editable",
63
+ position: {
64
+ x: node.position.x,
65
+ y: node.position.y
66
+ },
67
+ data: nodeData(node.data.label)
68
+ }));
69
+ }
70
+ function toFlowEdges(payload) {
71
+ return payload.edges.map((edge) => ({
72
+ id: edge.id,
73
+ source: edge.source,
74
+ target: edge.target
75
+ }));
76
+ }
77
+ // Local state is seeded from the `data` prop ONCE, inside `untrack` so it
78
+ // deliberately does not subscribe to later changes — `resetTo()` is how a
79
+ // parent replaces the diagram. Subscribing here would loop xyflow's
80
+ // per-frame mutations against the parent's draft.
81
+ let nodes = $state(untrack(() => toFlowNodes(data)));
82
+ let edges = $state(untrack(() => toFlowEdges(data)));
83
+ /** Replace the canvas state with the given payload. */
84
+ export function resetTo(payload) {
85
+ nodes = toFlowNodes(payload);
86
+ edges = toFlowEdges(payload);
87
+ }
88
+ /** Strip xyflow's internal state and return the persisted shape. */
89
+ export function getSnapshot() {
90
+ return {
91
+ nodes: nodes.map((node) => ({
92
+ id: node.id,
93
+ position: {
94
+ x: node.position.x,
95
+ y: node.position.y
96
+ },
97
+ data: { label: node.data.label ?? "" }
98
+ })),
99
+ edges: edges.map((edge) => ({
100
+ id: edge.id,
101
+ source: edge.source,
102
+ target: edge.target
103
+ }))
104
+ };
105
+ }
106
+ /** Append a node with the given label at a staggered default position. */
107
+ export function addNode(label = newNodeLabel) {
108
+ // Stagger new nodes diagonally so repeated "Add node" presses do not pile
109
+ // them on top of each other.
110
+ const offset = nodes.length * 30;
111
+ nodes = [...nodes, {
112
+ id: newId("n"),
113
+ type: "editable",
114
+ position: {
115
+ x: 60 + offset,
116
+ y: 60 + offset
117
+ },
118
+ data: nodeData(label)
119
+ }];
120
+ }
121
+ const nodeTypes = { editable: EditableNode };
122
+ function newId(prefix) {
123
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
124
+ }
125
+ function handleConnect(connection) {
126
+ if (!interactive) return;
127
+ edges = [...edges, {
128
+ id: newId("e"),
129
+ source: connection.source,
130
+ target: connection.target
131
+ }];
132
+ }
133
+ </script>
134
+
135
+ <div {...rest} class={['flowchart-canvas', className]}>
136
+ {#if browser}
137
+ <SvelteFlow
138
+ bind:nodes
139
+ bind:edges
140
+ {nodeTypes}
141
+ nodesDraggable={interactive}
142
+ nodesConnectable={interactive}
143
+ elementsSelectable={interactive}
144
+ edgesFocusable={interactive}
145
+ panOnDrag={interactive}
146
+ zoomOnScroll={interactive}
147
+ zoomOnPinch={interactive}
148
+ zoomOnDoubleClick={false}
149
+ panOnScroll={false}
150
+ onconnect={handleConnect}
151
+ fitView
152
+ >
153
+ <Background />
154
+ {#if interactive}
155
+ <Controls />
156
+ {/if}
157
+ </SvelteFlow>
158
+ {:else}
159
+ <p class="text-base-content/60 p-4 text-sm">
160
+ {summaryLabel(data.nodes.length, data.edges.length)}
161
+ </p>
162
+ {/if}
163
+ </div>
164
+
165
+ <style>
166
+ .flowchart-canvas {
167
+ width: 100%;
168
+ position: relative;
169
+ }
170
+ </style>
@@ -0,0 +1,27 @@
1
+ import '@xyflow/svelte/dist/style.css';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { FlowchartData } from './node/schema.ts';
4
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'class'> {
5
+ /** Persisted flowchart payload — used to initialise the canvas. */
6
+ data: FlowchartData;
7
+ /** Enable the add / move / connect / edit / delete affordances. */
8
+ interactive?: boolean | undefined;
9
+ /** Classes on the canvas container; merged last, so they win. */
10
+ class?: string | undefined;
11
+ /** The server-render fallback sentence, given the node and edge counts. */
12
+ summaryLabel?: ((nodes: number, edges: number) => string) | undefined;
13
+ /** Default label for a node added by {@link addNode}. */
14
+ newNodeLabel?: string | undefined;
15
+ /** Accessible name of the label edit field inside a node. */
16
+ nodeLabelLabel?: string | undefined;
17
+ /** Shown, and committed, when a node's label is blank. */
18
+ untitledLabel?: string | undefined;
19
+ }
20
+ /** The `@xyflow/svelte` canvas for a `FlowchartData` payload: read-only by default, editable with `interactive`; pull the result with `getSnapshot()`. */
21
+ declare const Flowchart: import("svelte").Component<Props, {
22
+ resetTo: (payload: FlowchartData) => void;
23
+ getSnapshot: () => FlowchartData;
24
+ addNode: (label?: string) => void;
25
+ }, "">;
26
+ type Flowchart = ReturnType<typeof Flowchart>;
27
+ export default Flowchart;