# Visual Linker — AI Reference `@macrulez/visual-linker-core` / `-vue` / `-nuxt` — a framework-agnostic engine that draws auto-routed SVG connector lines between DOM blocks you already control, with a Vue 3 adapter (component + directives + composable) and a Nuxt module wrapping it. Version 0.4.3. Overlay mode (you position the blocks, the engine draws the lines) is the only implemented mode — mouse-driven connection creation and an auto-layout mode are NOT implemented yet (see the package's own docs/TECH_SPEC.md). This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs), and prose is kept to the minimum needed to use the API correctly. For human-readable narrative docs (why you'd reach for each piece, worked examples), see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/visual-linker/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/visual-linker/guide/overview - GitHub: https://github.com/macrulezru/visual-linker - npm: https://www.npmjs.com/package/@macrulez/visual-linker-core Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map — what to import from where | Package | Install | Peer deps | Provides | |---|---|---|---| | `@macrulez/visual-linker-core` | `npm install @macrulez/visual-linker-core` | none | `createVisualLinker()`, the themes (`lightTheme`/`darkTheme`/`defineTheme`) and the config helpers (`mergeConfig`/`patchConfig`) — the framework-agnostic engine (section 3). | | `@macrulez/visual-linker-vue` | `npm install @macrulez/visual-linker-vue` | `vue: ^3.3.0` | ``, the `v-vl-block`/`v-vl-port` directives, `VisualLinkerPlugin`, `useVisualLinker()`, the shared-config API `useVisualLinkerConfig()` etc. (section 4), **plus `export * from '@macrulez/visual-linker-core'`** — the full core surface. | | `@macrulez/visual-linker-nuxt` | `npm install @macrulez/visual-linker-nuxt` | `nuxt: ^3.9.0 \|\| ^4.0.0` | Nuxt module: auto-imports ``/`useVisualLinker` from `-vue`, registers `v-vl-block`/`v-vl-port` (server + client), installs the `visualLinker` key of `nuxt.config.ts` as the app-wide shared configuration (section 5). Depends on `-vue` internally. | **Rule: install exactly one package and import everything from it** — `-vue` re-exports 100% of `-core`, including the enums (`VLConnectionCurveEnum` etc.) and every type. Installing `-core` alongside `-vue` is redundant, never required. `-nuxt` doesn't need `-vue` installed separately either. --- ## 2. Core types (verbatim from `@macrulez/visual-linker-core`'s `types.ts`/`enums.ts`) 0.4.0 REPLACED the flat `VisualLinkerOptions` (and every `defaultXxx`) with ONE structured `VisualLinkerConfig`. There is no `VisualLinkerOptions` any more. ```ts enum VLConnectionCurveEnum { BEZIER = 'bezier', STRAIGHT = 'straight', SMOOTHSTEP = 'smoothstep' } enum VLMarkerShapeEnum { CIRCLE = 'circle', SQUARE = 'square', DIAMOND = 'diamond', ARROW = 'arrow' } enum VLFixedSideEnum { TOP = 'top', RIGHT = 'right', BOTTOM = 'bottom', LEFT = 'left', AUTO = 'auto' } enum VLOrientEnum { AUTO = 'auto', FIXED = 'fixed' } type FixedSide = 'top' | 'right' | 'bottom' | 'left' // (0.4.1) plain strings; enum members are accepted too type PortSide = FixedSide | 'auto' type ConnectionCurve = 'bezier' | 'straight' | 'smoothstep' type MarkerShape = 'circle' | 'square' | 'diamond' | 'arrow' // Write `curve: 'straight'`, `shape: 'arrow'`, `side: ['left', 'right']` as plain strings — they type-check. // `VLConnectionCurveEnum.STRAIGHT` etc. is also accepted and has the same value. (Before 0.4.1 only enum // members type-checked in TypeScript; strings still worked at runtime.) // ---- entities (descriptors) ---- interface PortDescriptor { id: string target?: string | HTMLElement // CSS selector (resolved against block.el) or direct element. default: block's own el side?: PortSide | FixedSide[] // default: config.ports.side, else 'auto'. FixedSide[] narrows auto's candidate sides offset?: number // 0..1 along the resolved side. default: config.ports.offset, else 0.5. Ignored if anchorBlockId/anchorEl set anchorBlockId?: string // draws the point on ANOTHER block's border, tracking target's real position projected onto it. Ignored if anchorEl set anchorEl?: HTMLElement // like anchorBlockId but a direct element. Wins over anchorBlockId if both set spread?: PortSpread // overrides the block's portSpread; false opts this port out } interface PortSpreadOptions { gap?: number /* 16 */; padding?: number /* 8 */ } type PortSpread = boolean | PortSpreadOptions // true = defaults, false = explicitly off (beats an inherited setting) interface DragBoundsInset { top?: number; right?: number; bottom?: number; left?: number } // px, unset edges default 0 type DragBounds = 'container' | HTMLElement | DragBoundsInset interface BlockDescriptor { id: string el: HTMLElement ports?: PortDescriptor[] // default: connections anchor to el's own border draggable?: boolean // overrides config.blocks.draggable for this block dragHandle?: string | HTMLElement // default: whole block starts the drag dragBounds?: DragBounds // overrides config.blocks.drag.bounds for this block portSpread?: PortSpread // overrides config.ports.spread for every port side of this block; a port's own `spread` wins highlightable?: boolean // (0.4.3) overrides config.interaction.highlight for this block: hovering it highlights its connections } interface ConnectionEndpoint { blockId: string; portId?: string } // portId unset → block's own border, side 'auto' interface ConnectionDescriptor { id: string; from: ConnectionEndpoint; to: ConnectionEndpoint style?: ConnectionStyle // SAME SHAPE as config.lines, plus `markers` hoverable?: boolean // (0.4.3) overrides config.interaction.hover for this connection: the `hover` state + pointer cursor ariaLabel?: string // default 'Connection: {from block id} → {to block id}' labels?: ConnectionLabel[] // omitted → Vue's single #connection-label slot at the midpoint } interface ConnectionLabel { id: string // unique within the connection position: 'start' | 'middle' | 'end' | number // 'start'/'end' sit 24px in (at most half the line) from that end; number = fraction of length, 0..1 offset?: number // px along the line's normal; POSITIVE = right of the direction of travel. default 0 text?: string // set → drawn by the library (SVG pill); unset → only a position for your own content className?: string // on the library-drawn label's ; parts .vl-label-bg / .vl-label-text rotate?: boolean // follow the line, kept upright. default false } // ---- states ---- type VisualState = 'highlight' | 'hover' | 'selected' | 'focus' type Stateful = T & { highlight?: Partial; hover?: Partial; selected?: Partial; focus?: Partial } // Layering, weakest → strongest: base < selected < highlight < hover < focus. `hover` = pointer over the connection's own line; // `highlight` = pointer over one of its BLOCKS. With no `highlight` bucket (at any level) highlight behaves exactly like hover. // Markers, ports and labels follow the state of THEIR connection. Blocks have no states. // ---- the configuration ---- interface VisualLinkerConfig { theme?: Theme lines?: LinesConfig markers?: MarkersConfig ports?: PortsConfig labels?: LabelsConfig blocks?: BlocksConfig interaction?: InteractionConfig } interface LineStyle { color?: string /* '#2e8b57' */; width?: number /* 1.5 */; dashed?: boolean; opacity?: number /* 0..1 */ } interface LineOptions { curve?: ConnectionCurve // default 'bezier' animated?: boolean | ConnectionFlow // default off; false on a connection opts out of a group-level setting bezier?: { curvature?: number /* 0.5 */; minReach?: number /* 24 */; maxReach?: number /* 160 */; angleBlend?: number /* 0.55 */; angleMaxOffset?: number /* degrees, 30 */ } // bezier only smoothstep?: { cornerRadius?: number /* 8 */; maxTrunkReach?: number /* 48; only for 2+ siblings sharing (block,port,side); smallest wins */ } // smoothstep only routing?: { avoidObstacles?: boolean /* false */; padding?: number /* 12 */ } // smoothstep only jumps?: boolean | { radius?: number /* 5 */ } // smoothstep only; default off } type LinesConfig = Stateful & LineOptions type ConnectionStyle = LinesConfig & { markers?: { start?: MarkerInput; end?: MarkerInput } } interface ConnectionFlow { speed?: number // px per SECOND. default 60 direction?: 'forward' | 'backward' // forward = from → to shape?: 'dashes' | 'dots' // default 'dashes' dash?: number // ignored for dots. default 8 gap?: number // default 14 (12 for dots) color?: string // unset = TINT mode: pattern in the line's own color, line dimmed to 0.35 (0.7 while emphasised). Set = this color over the unchanged line width?: number // default: line's width in tint mode; with an explicit color 60% of it (dots 120%, min 2.5px) } interface MarkerArrowConfig { color?: string /* follows the line color, NOT the shape's fill */; gap?: number /* 0 */ } interface MarkerStyle { shape?: MarkerShape // default 'circle'. Ignored if svg is set size?: number // multiple of the current stroke width. default markers.sizes[shape] = 6 for EVERY shape color?: string // fill. default: the connection's line color IN THE CURRENT STATE (theme-aware). Ignored by 'arrow' strokeColor?: string // outline. default none. No effect on 'arrow' strokeWidth?: number // default 1. Ignored if strokeColor unset opacity?: number // default: the line's opacity (drawn inside , since markers don't inherit stroke-opacity) className?: string // CSS class on the marker's root element svg?: string // raw inner SVG markup, overrides shape, 0 0 20 20 viewBox — NOT sanitized orient?: 'auto' | 'fixed' // default 'auto' for 'arrow', 'fixed' otherwise. FORCED to 'auto' when `arrow` is set arrow?: boolean | MarkerArrowConfig // also an arrowhead right before the shape; tip lands on the shape's OUTER edge. Ignored when shape is 'arrow' } type MarkerConfig = Stateful // a state bucket can change shape, svg, size, colors, outline, arrow type MarkerInput = false | MarkerShape | MarkerConfig // false = no marker AND no built-in port dot at that end (a bare point) interface MarkersConfig { start?: MarkerInput; end?: MarkerInput // configured here → replaces the built-in port dot at that end of EVERY connection sizes?: { circle?: number; square?: number; diamond?: number; arrow?: number } // each 6 } interface PortStyle { radius?: number /* 4 */; fill?: string /* '#fff' */; stroke?: string /* follows the line color */; strokeWidth?: number /* 1.5 */; opacity?: number } type PortsConfig = Stateful & { show?: boolean // default true. The #port slot and the `layout` event work either way side?: PortSide | FixedSide[] // default 'auto' — for ports that set none offset?: number // default 0.5 — for ports that set none spread?: PortSpread // default off } interface LabelStyle { background?: string; border?: string; color?: string; fontSize?: number /* 11 */; paddingX?: number /* 6 */; paddingY?: number /* 3 */; opacity?: number } type LabelsConfig = Stateful // library-drawn labels only (those with `text`) interface BlocksConfig { draggable?: boolean /* false */; drag?: { grid?: number /* px, unset = free */; bounds?: DragBounds /* unset = unconstrained */ } } interface InteractionConfig { hover?: boolean /* false (0.4.3) */; highlight?: boolean /* false (0.4.3) */; selectable?: boolean /* false */; clipToScrollParents?: boolean | 'pin' | 'hide' /* 'pin'; true = 'pin' */ } interface Theme { // color tokens → CSS variables written on the line?: string // --vl-line-color #2e8b57 (lightTheme) lineHover?: string // --vl-line-color-active #2e8b57 (hover AND highlight) lineSelected?: string // --vl-line-color-selected #2e8b57 (falls back to lineHover) selectedHalo?: string // --vl-selected-color #1f6feb focusRing?: string // --vl-focus-color #1f6feb portFill?: string // --vl-port-fill #ffffff portStroke?: string // --vl-port-stroke-color #2e8b57 labelBackground?: string // --vl-label-bg #ffffff labelBorder?: string // --vl-label-border #2e8b57 labelText?: string // --vl-label-color #1c1e2b } const lightTheme: Theme; const darkTheme: Theme function defineTheme(overrides: Theme, base: Theme = lightTheme): Theme // a copy of base with overrides function mergeConfig(...layers: (T | undefined)[]): T // deep merge; later wins; `undefined` values IGNORED; plain objects only (DOM elements/arrays are opaque) function patchConfig(base: T, patch: T): T // deep merge where `undefined` DELETES the key function mergeMarkerInputs(base: MarkerInput | undefined, over: MarkerInput | undefined): MarkerConfig | false | undefined // ---- outputs ---- interface ConnectionLayout { // one per connection, fired on the 'layout' event — coordinates LOCAL to the container id: string; from: Point; to: Point; mid: Point; fromAngle: number; toAngle: number // angles in degrees labels: LabelLayout[] // every ConnectionDescriptor.labels entry resolved onto the line (empty without `labels`) fromClipped?: boolean; toClipped?: boolean // that end was pinned to a clipping ancestor's edge (the real port is out of view) } interface LabelLayout { id: string; point: Point; angle: number; rotation: number; text?: string; className?: string } // point includes `offset`; rotation = upright(angle) if rotate else 0 interface PortLayout { // one per resolved, DISTINCT physical point (deduped by rounded x/y, not by (blockId,portId)) that has no configured marker key: string; blockId: string; portId?: string; point: Point } interface VisualLinkerEventMap { 'block:dragstart': { blockId: string } 'block:drag': { blockId: string; x: number; y: number } 'block:dragend': { blockId: string; x: number; y: number } 'block:mouseenter': { blockId: string } // always fires; puts the block's connections into the `highlight` state only with interaction.highlight / block.highlightable 'block:mouseleave': { blockId: string } 'connection:click': { connection: ConnectionDescriptor } 'connection:mouseenter': { connection: ConnectionDescriptor } // always fires; puts it into the `hover` state only with interaction.hover / connection.hoverable 'connection:mouseleave': { connection: ConnectionDescriptor } 'connection:selectionchange': { selectedIds: string[] } // user input only — NOT fired by setSelectedConnections() 'connection:delete-request': { connections: ConnectionDescriptor[] } // Delete/Backspace: the selected ones, or just the focused one when it isn't selected. Nothing is removed layout: { connections: ConnectionLayout[]; ports: PortLayout[] } // fired at the end of EVERY render pass } ``` **Resolution order for any visual setting** (strongest first): the field on the entity (connection `style`, port, block, label) → the matching group of the engine's config → the shared config of the Vue app / Nuxt project (merged under the component's own `config`) → the theme token → a CSS variable of the app → the built-in value. Layers are merged FIELD BY FIELD and STATE BY STATE. A state bucket ranks above base fields at EVERY level: group `hover.color` beats a connection's own base `color` while hovered, unless the connection sets `hover.color` too. --- ## 3. `@macrulez/visual-linker-core` ### 3.1 `createVisualLinker(container, config?)` ```ts function createVisualLinker(container: HTMLElement, config?: VisualLinkerConfig): VisualLinker interface VisualLinker { setBlocks(blocks: BlockDescriptor[]): void // full replace, triggers immediate render setConnections(connections: ConnectionDescriptor[]): void // full replace, triggers immediate render updateBlock(id: string, patch: Partial>): void // merge; no-op if id not registered; id itself cannot be changed via patch addConnection(connection: ConnectionDescriptor): void // incremental add removeConnection(id: string): void // incremental remove setSelectedConnections(ids: readonly string[]): void // replaces the selection (interaction.selectable only); ids not among the connections are dropped; emits NO event setConfig(patch: VisualLinkerConfig): void // (0.4.0) deep-merge; a key patched to `undefined` is REMOVED; everything redraws, listeners (drag, selection) are re-attached/removed to match replaceConfig(next: VisualLinkerConfig): void // (0.4.0) replaces the whole configuration getConfig(): VisualLinkerConfig // (0.4.0) a COPY of the configuration in effect refresh(): void // forces immediate recompute, bypasses the rAF batch on(event: E, handler: (payload) => void): () => void // returns unsubscribe; no separate off() method destroy(): void } ``` ```ts import { createVisualLinker } from '@macrulez/visual-linker-core' const container = document.querySelector('#diagram')! const linker = createVisualLinker(container, { lines: { curve: 'smoothstep' } }) linker.setBlocks([ { id: 'a', el: document.querySelector('#block-a')! }, { id: 'b', el: document.querySelector('#block-b')!, ports: [{ id: 'in', side: 'left' }] }, ]) linker.setConnections([ { id: 'a-b', from: { blockId: 'a' }, to: { blockId: 'b', portId: 'in' }, style: { markers: { end: 'arrow' } } }, ]) const unsubscribe = linker.on('connection:click', ({ connection }) => console.log(connection.id)) // later: unsubscribe(); linker.destroy() ``` - **Auto-sets `container.style.position = 'relative'` if its computed `position` is `'static'`** (`svg-layer.ts`) — you do NOT have to set a positioning context yourself; the engine does it for you the first time it runs. Only relevant if `container`'s computed position is already something other than `static` (e.g. `absolute`/`fixed`) — those are left untouched. - SVG layer: one `` absolutely positioned (`inset: 0`, `pointer-events: none`) appended as the LAST child of `container`, with its own ``/``, cached per element until the block list changes. The visible rect = intersection of their rects on the axes they clip; a port outside it (1px tolerance) is clipped. `'pin'` clamps the point into the rect, drops that end's marker and port dot and sets `fromClipped`/`toClipped` on the layout; `'hide'` drops the connection. - **Port spreading (`ports.spread` / `portSpread` / `spread`).** Connections sharing a `(block, port, side)` get virtual ports `gap` apart, centered on the port's point (shrinking the gap to fit inside the side minus `padding`), ordered by where each connection's OTHER end lies so lines don't cross; they re-order live while dragging. Works on horizontal and vertical sides. `false` at any level beats an inherited `true`. ### 3.3 State model and runtime changes (0.4.0) - **How a look is resolved.** For a connection the engine builds a `view` = `mergeConfig(config.lines, connection.style)` (deep, state-by-state), then `fields = base ← selected ← highlight ← hover ← focus` for the states that are active. Width: a state `width` wins; otherwise an emphasised (hover / highlight / selected) line with an explicit base width gets `+1.5`. Markers: `mergeMarkerInputs(config.markers[end], style.markers[end])` then the same layering, with the line's color of the moment as the `color` fallback and the line's opacity as the `opacity` fallback. Ports/labels use the strongest state among the connections that own them. A marker/dot configured (even `false`) at an end REPLACES the built-in port dot there; `PortLayout` skips it. - **Opacity.** `stroke-opacity` = line `opacity` × the tint dimming of an animated line (0.35, 0.7 when emphasised); the flow overlay gets the plain `opacity`. Markers get ``; ports `style.opacity`; labels `` style. - **Hover vs highlight.** `block:mouseenter` → `highlight` for the block's connections; a connection's own hit path `pointerenter` → `hover`. Both count as "emphasised" for width bump, painting order and hiding the markers of siblings that share an endpoint. - **Both are OPT-IN (0.4.3).** `hover` needs `interaction.hover` (or `connection.hoverable`), `highlight` needs `interaction.highlight` (or `block.highlightable`); the local value wins over the global one. A `hover`/`highlight` bucket does nothing while its mode is off. The pointer cursor on a line appears only when hover is on for it OR the line is `selectable`. The engine events (`connection:mouseenter`/`mouseleave`/ `click`, `block:mouseenter`/`mouseleave`) fire regardless; `selected` and `focus` do not depend on the flags. Both flags are read at paint/hover time, so `setConfig({ interaction: { hover: true } })` takes effect at once and switching it off ends a hover in progress. - **`setConfig` / `replaceConfig`.** Re-resolve everything: theme variables are rewritten, port/label/line appearance recomputed, hit paths re-configured (`tabindex`/`role`/`aria-pressed` on or off with `interaction.selectable`), document listeners for selection attached/removed, block drag listeners rebuilt, then a full render. Turning `selectable` off clears the selection (emitting `connection:selectionchange`). `getConfig()` is a copy. --- ## 4. `@macrulez/visual-linker-vue` (own exports — also re-exports all of section 2–3) ### 4.1 `` ```ts type RefFriendlyElement = string | MaybeRefOrGetter // CSS selector string, OR a Vue ref/getter/plain element — NOT a selector wrapped in a ref type RefFriendlyDragBounds = 'container' | RefFriendlyElement | DragBoundsInset interface RefFriendlyPort extends Omit { // every other PortDescriptor field (id, side, offset, anchorBlockId) unchanged target?: RefFriendlyElement anchorEl?: MaybeRefOrGetter } interface VisualLinkerBlock { // an entry of the OPTIONAL `blocks` prop id: string el?: RefFriendlyElement // ref/getter/element, or a CSS selector (resolved inside the component's root, or across `document` with scope="page"). OMITTED → the entry only adds config to a block already marked in the template (v-vl-block/data-vl-block) with the same id; if no such block exists, the entry is dropped ports?: RefFriendlyPort[] draggable?: boolean highlightable?: boolean // (0.4.3) overrides config.interaction.highlight for this block dragHandle?: RefFriendlyElement dragBounds?: RefFriendlyDragBounds portSpread?: PortSpread } type VisualLinkerScope = 'container' | 'page' // Props: // connections: ConnectionDescriptor[] — REQUIRED. watch(..., { deep: true }) // blocks: VisualLinkerBlock[] — default []. Optional: blocks are normally marked in the markup (4.4) // config: VisualLinkerConfig — default {}. (0.4.0; was `options`.) REACTIVE: merged over the shared config, and a change calls engine.replaceConfig(...) // selected: string[] — (0.3.0) default undefined. Ids of selected connections (v-model:selected), needs config.interaction.selectable. Unset → the engine owns the selection. Watched deeply; applied AFTER setConnections so ids aren't pruned // scope: VisualLinkerScope — default 'container' // name: string — default undefined. Target for data-vl-linker / the directives' `linker` option // zIndex: number | string — default undefined. z-index of the drawing layer // inheritAttrs: false — any other attribute (class, style, id, …) is merged onto the ROOT div // Emits (kebab-case, 1:1 with VisualLinkerEventMap, `block:x` → `block-x`, `connection:x` → `connection-x`): // block-dragstart, block-drag, block-dragend, block-mouseenter, block-mouseleave, // connection-click, connection-mouseenter, connection-mouseleave // (0.3.0) connection-selectionchange (string[] ids), connection-delete-request (ConnectionDescriptor[]), update:selected (string[]) // Slots: // default — YOUR markup. Blocks/ports marked anywhere inside it (any depth, any wrapper components) are discovered // connection-label — { connection, label?, point, angle?, rotation?, from, to } — only built if used. WITHOUT `connection.labels`: once, at the curve-aware midpoint (as before). WITH `labels` (0.3.0): once per label that has NO `text` (labels with text are drawn by the engine), at label.point rotated by `rotation` // port — { blockId, portId?, point } — only built if used // marker — { connection, position: 'start'|'end', point, angle } — only rendered for an endpoint with NO marker configured (neither config.markers[pos] nor style.markers[pos]) and NOT for an end pinned to a scrolling container's edge (layout.fromClipped/toClipped) // There are NO per-block slots and NO per-block wrapper elements. ``` ```vue ``` - Render tree: `
` (attrs merged in; `position: relative` ONLY in container scope) containing the default slot and — in container scope — the drawing layer as its LAST child. The drawing layer is `
` (`position: absolute; inset: 0; pointer-events: none; z-index: props.zIndex`); in page scope it's `
` with `position: fixed; inset: 0`, rendered through ``, and the component returns a fragment `[root, Teleport]`. - The engine is created on the LAYER element (`createVisualLinker(layerEl, mergeConfig(shared, props.config))`) — not on the root, and not on your markup. The SVG and the `.vl-overlay` div (holding whichever of the three overlay slots are used) both live inside the layer. Consequence: `blocks.drag.bounds: 'container'` means the component's own box in container scope, and the VIEWPORT in page scope. - The layer is rendered only after `onMounted` (client only) — the SSR output contains the root and your slot content, but no layer; this keeps the server render and the hydration pass identical. - Discovery (details in 4.4): a `MutationObserver` on the root (on `document.body` in page scope) watching `childList`, `subtree`, and the `data-vl-*` attributes (every `VL_ATTR` except `data-vl-root`) schedules a rescan; mutations inside `.vl-layer` are ignored. Directive mount/update/unmount also triggers a rescan of every live ``. A rescan runs inside a `watchEffect` (so refs read via `toValue()` — a `blocks` entry's `el`, a port's `target` — are tracked and re-sync once they resolve) and only calls `engine.setBlocks()` when the collected list is structurally different from the previous one. - `engine.destroy()` is called automatically in `onBeforeUnmount` (observer disconnected, scope unregistered) — do not call it yourself from outside. - Engine config: `mergeConfig(sharedConfig, props.config)` — recomputed on every change of either (the shared object is `reactive`), then `engine.replaceConfig()` — see 4.3. - **SSR-safe**: no engine and no layer on the server. No `` wrapper needed anywhere, in plain Vue or in Nuxt (section 5). ### 4.2 `useVisualLinker(container, options?)` ```ts interface UseVisualLinkerOptions { // (0.4.0) no longer extends the engine options config?: MaybeRefOrGetter // ref, getter or plain object; merged over the shared config; watched → engine.replaceConfig() blocks?: MaybeRefOrGetter // RefFriendlyBlock = BlockDescriptor with el/dragHandle/dragBounds/port.target/port.anchorEl all ref/getter-friendly connections?: MaybeRefOrGetter } interface UseVisualLinkerReturn { engine: ShallowRef } function useVisualLinker( container: MaybeRefOrGetter, options?: UseVisualLinkerOptions, ): UseVisualLinkerReturn ``` - `engine.value` is `null` until `onMounted` AND `toValue(container)` resolves to a real element — check for `null` before calling engine methods synchronously after the composable call. - If `options.blocks` is passed, it's synced via the SAME `watchEffect` pattern as the component (4.1) — omit it entirely to manage blocks yourself via `engine.value.setBlocks(...)`. - If `options.connections` is passed, same `watch(..., { deep: true })` as the component. Omitting `blocks`/`connections` means NEITHER is ever auto-synced — the returned `engine` is otherwise a completely raw `VisualLinker` instance. - `engine.value.destroy()` is called automatically in `onBeforeUnmount`. - Same SSR-safe no-op behavior as the component (4.1) — engine creation happens only in `onMounted`. - Must be called in `setup()` (it `inject`s the shared config). ### 4.3 Shared configuration (0.4.0) — `provide`/`inject`, NOT a global REMOVED in 0.4.0: `visualLinkerDefaults`, `setVisualLinkerDefaults()`, `resetVisualLinkerDefaults()`, the `VisualLinkerDefaults` type. Use: ```ts app.use(VisualLinkerPlugin, { config?: VisualLinkerConfig }) // provides a reactive shared config to the app + registers and both directives function useVisualLinkerConfig(): VisualLinkerConfig // the reactive shared config in effect; THROWS ("no shared config…") if nothing provides one function provideVisualLinkerConfig(initial?: VisualLinkerConfig): VisualLinkerConfig // in setup(): provides a NEW reactive config to the subtree (REPLACES the app-level one, not merged) and returns it function installVisualLinkerConfig(app: App, initial?: VisualLinkerConfig): VisualLinkerConfig // same for a whole app (what the Nuxt plugin calls) function createSharedConfig(initial?: VisualLinkerConfig): VisualLinkerConfig // the reactive object, not provided const VISUAL_LINKER_CONFIG_KEY: InjectionKey ``` ```ts const shared = useVisualLinkerConfig() shared.theme = darkTheme // every diagram under the provider redraws shared.lines = { ...shared.lines, curve: 'straight' } delete shared.theme // removes the key ``` - Every ``/`useVisualLinker()` computes `mergeConfig(shared ?? undefined, ownConfig)` (own wins, field by field, state by state) inside a reactive getter, so a change to the shared object OR to the own config re-calls `engine.replaceConfig()`. - No provider → components use only their own `config` (no error). State belongs to the app: nothing global, several Vue apps on a page are independent. ### 4.4 Marking blocks and ports: `v-vl-block` / `v-vl-port` / `data-vl-*` ```ts interface BlockDirectiveOptions { id: string linker?: string // assigns the block to the with this name, wherever it sits draggable?: boolean highlightable?: boolean // (0.4.3) overrides config.interaction.highlight for this block dragHandle?: RefFriendlyElement // selector inside the block, or ref/getter/element dragBounds?: RefFriendlyDragBounds // a selector string here resolves against `document` portSpread?: PortSpread // (0.3.0). A port directive takes `spread` (it is a PortDescriptor field) } type BlockDirectiveValue = string | BlockDirectiveOptions // v-vl-block="'b1'" ≡ { id: 'b1' } interface PortDirectiveOptions extends Omit { // id, side, offset, anchorBlockId, anchorEl block?: string // owning block id. OMITTED → the nearest ancestor element registered as a block linker?: string } type PortDirectiveValue = string | PortDirectiveOptions // v-vl-port="'out'" ≡ { id: 'out' } const vVlBlock: ObjectDirective const vVlPort: ObjectDirective const VL_ATTR: { root: 'data-vl-root', linker: 'data-vl-linker', block: 'data-vl-block', draggable: 'data-vl-draggable', highlightable: 'data-vl-highlightable', dragHandle: 'data-vl-drag-handle', dragBounds: 'data-vl-drag-bounds', port: 'data-vl-port', portBlock: 'data-vl-port-block', side: 'data-vl-side', offset: 'data-vl-offset', anchor: 'data-vl-anchor', portSpread: 'data-vl-port-spread', spread: 'data-vl-spread', // (0.3.0) } ``` - A directive-marked port's measured element (`target`) is ALWAYS the element carrying the directive — `target` is not an option. - The directives mirror `data-vl-block`/`data-vl-port` (+ `data-vl-port-block`/`data-vl-linker` when set) onto the element; every other option lives off-DOM (a `WeakMap` keyed by the element), since it may hold refs/elements. `getSSRProps` emits those same attributes during server rendering. - Plain attributes (no JS, e.g. server-rendered or third-party markup) — same registry: - `data-vl-block="id"` — block. `data-vl-draggable` — `""`/`"true"` → true, `"false"` → false (anything but the literal `"false"` is true). `data-vl-highlightable` — same values, for `highlightable`. `data-vl-drag-handle=".sel"` — selector inside the block. `data-vl-drag-bounds` — `"container"`, or a CSS selector resolved via `document.querySelector`. - `data-vl-port="id"` — port on the nearest ancestor block; `data-vl-port-block="id"` — or on this block explicitly. `data-vl-side` — one side (`top|right|bottom|left|auto`), or a space/comma-separated list → `FixedSide[]` (`auto` dropped from a list; unknown words ignored). `data-vl-offset` — number, non-finite/empty ignored. `data-vl-anchor="id"` → `anchorBlockId`. - `data-vl-port-spread` (block) / `data-vl-spread` (port) — (0.3.0) `""`/`"true"` → on with defaults, `"false"` → off, `"24"` → gap 24, `"24 4"` (or `"24,4"`) → gap 24 + padding 4. - `data-vl-linker="name"` — on a block or a port. - Collection order for one scan: (1) every `[data-vl-block]` in scope that this instance owns; (2) `blocks` prop entries layered on top, per field; (3) every owned `[data-vl-port]`, appended to its owner's `ports` AFTER any `blocks`-prop ports. Precedence for the same block id: **`blocks` prop > directive options > data attributes**. For the same port id on one block, a `blocks`-prop port wins (the engine uses the first match). - A port whose owner can't be found (no ancestor block, or `block`/`data-vl-port-block` naming an unknown id) is silently dropped. - Ownership: an explicit `data-vl-linker` wins — it matches only the instance with that exact `name` (an element with `data-vl-linker` belongs to NO unnamed instance, not even its enclosing one); otherwise the nearest enclosing `[data-vl-root]` (so nested instances never take each other's blocks); otherwise (outside every ``) only page-scoped instances own it. Search root: the component's root element in container scope, `document` in page scope. - Several page-scoped instances on one page all claim every unowned element — give each a `name` and assign elements explicitly. ### 4.5 `VisualLinkerPlugin` ```ts const VisualLinkerPlugin: Plugin // app.use(VisualLinkerPlugin) → app.component('VisualLinker'), app.directive('vl-block'), app.directive('vl-port') ``` Without the plugin, import `VisualLinker`, `vVlBlock`, `vVlPort` per component — in `