Foblex Flow — working rules for skill-map
Foblex Flow (@foblex/flow) is the graph library that powers ui/src/app/views/graph-view/. Upstream documentation is sparse, so this skill is the authoritative operational guide. Before writing or reviewing any graph-related code, read the non-negotiables below.
Installed version: 19.1.6 (migrated from 18.6.1). The migration adopted v19's unified [fConnector] model, the renamed f-connection endpoint inputs (fSourceId / fTargetId), the opt-in keyboard a11y layer (provideFFlow(withA11y(...))), and the Foblex-owned selection contract. Legacy names still work but are deprecated; new code uses the v19 names.
Reference material (load on demand):
references/api-reference.md— every directive, component, input, output, event, method, CSS class, theme token, and enum.references/examples/— verbatim copies of every official example fromlibs/f-examples/in the Foblex repo, organized by category (nodes, connectors, connections, extensions, plugins, advanced, reference-apps). Start atreferences/examples/README.mdfor the index and the six canonical patterns that repeat across all examples. When in doubt, the examples win — they are the Foblex team's own reference shape.
Mental model
- Foblex Flow does NOT own graph state. Your app owns nodes, groups, connections, ids, validation, and persistence.
- Angular templates render the current state; user actions emit events; your app mutates state; Angular rerenders.
- Connections are connector-to-connector, NOT node-to-node. Each
<f-connection>names a source connector (fSourceId) and a target connector (fTargetId); both must match a registeredfConnectorId. (Pre-v19 these inputs werefOutputId/fInputId, still accepted but deprecated.) - Do NOT assume React-Flow-style APIs (
[nodes],[edges],setNodes(),addEdge()). Those do not exist.
The nine non-negotiables
Skipping any of these produces silent failures: missing visuals, degraded performance, or wrong positioning. All nine were learned the hard way — do not relitigate them, apply them.
1. One id per connector, one registry (unified [fConnector] model)
v19 replaces the legacy fNodeInput / fNodeOutput / fNodeOutlet directives with a single [fConnector] directive whose role is set by fConnectorType ('source' | 'target' | 'source-target' | 'outlet', default 'source-target'). All connector ids now live in one registry: there are no separate input/output namespaces anymore, so a fConnectorId must be unique across the flow, and one id can serve as both endpoint roles via source-target.
skill-map's canonical shape (the same-element pattern, directives on the [fNode] host):
<div fNode fDragHandle
fConnector fConnectorType="source-target"
[fNodeId]="node.id"
[fNodePosition]="node.position"
[fConnectorId]="node.id">
<sm-node-card … />
</div>
Connector ids are plain node ids. The old node.id + '-in' / node.id + '-out' suffix convention is gone: with one connector per node and a unified registry there is nothing to disambiguate, and <f-connection> binds [fSourceId]="edge.from" / [fTargetId]="edge.to" directly. Session anchors (spawn overlay) use the same directive with a narrower role: fConnector fConnectorType="source" [fConnectorId]="session.id".
Two defaults changed with the unified directive, worth knowing when porting legacy code:
fConnectorMultipledefaults to true (a legacyfNodeOutputwas single-connection unlessfOutputMultiplewas set).fCanBeConnectedToreplaces the legacyfCanBeConnectedInputsfor connection allow-lists.
Legacy note: the old directives still work (deprecated since v19, one release of grace, do not write them in new code). Under them, ids lived in per-direction registries, so fInputId and fOutputId on the same node had to be different strings (hence the -in / -out suffixes you will still see in the official v18-era examples). Reusing node.id for both silently dropped every edge. If edges do not show and the console is clean, check that every fSourceId / fTargetId on your connections matches a registered fConnectorId (see troubleshooting #1).
2. Wire the theme — either the global default.scss OR per-view SCSS mixins
Connections and markers render as invisible SVG without the theme. Two supported paths:
Path A — global import via angular.json (what skill-map uses today):
Wire ../node_modules/@foblex/flow/styles/default.scss as the first entry in the styles array. Two gotchas:
- Workspace hoisting: this repo uses npm workspaces.
@foblex/flowhoists to the repo-rootnode_modules/, notui/node_modules/. The../is required — a literalnode_modules/@foblex/flow/...resolves relative toui/and fails. - Package
exportsblocks subpaths:@foblex/flow'spackage.jsondeclaresexportsthat only expose.and./package.json. A package-resolution specifier (@foblex/flow/styles/default.scss) is rejected by modern resolvers. The raw filesystem path via../node_modules/...bypasses exports and works.
angular.json changes do NOT hot-reload — dev server restart required.
Path B — per-view SCSS mixins (what every official libs/f-examples/* does):
@use '@foblex/flow/styles' as flow-theme;
::ng-deep { @include flow-theme.theme-tokens(); } // CSS vars (--ff-*)
::ng-deep f-flow {
@include flow-theme.flow($scoped: false); // base styles
@include flow-theme.connection($scoped: false); // connections
@include flow-theme.connection-markers($scoped: false); // markers
// …only the features this view uses
}
@include flow-theme.node($selectorless: false);
Available mixins: theme-tokens, flow, node, group, connector, connection, connection-markers, drag-handle, resize-handle, rotate-handle, minimap, selection-area, background, grid-system. Use this path when you need a feature only in one view (smaller CSS, no angular.json surgery), or when debugging theme drift — it makes it obvious which mixins the view depends on. Examples: references/examples/ → any example.scss.
Dark mode: the shipped theme already paints a full dark palette under .dark and [data-theme='dark'] (see @foblex/flow/styles/tokens/_semantic.scss — every --ff-color-* is redeclared in that block). Whatever class your app uses to flag dark mode (PrimeNG/Aura uses .app-dark here, registered via darkModeSelector in app.config.ts), make sure .dark is also toggled on documentElement so Foblex picks up its own dark tokens. The ThemeService in this repo flips both classes from a single signal.
Antipattern — do NOT redeclare --ff-color-* inside your own .app-dark { ... } block to "force" the graph into dark. That duplicates the package's palette, drifts the day Foblex updates a token, and is exactly the "papering over a missed setup step" pattern AGENTS.md prohibits. The fix is one line in the theme service, not a parallel token table.
3. Never animate or override properties Foblex controls via inline styles
Foblex applies transform: translate(x, y) inline on every [fNode] element (from fNodePosition) and on .f-canvas (from zoom/pan). App-level CSS that touches those transforms fights the library. Do NOT write:
transition: transform ...on a node class — every position update gets smoothed for the transition duration; connection paths recalculate mid-interpolation → visible lag on zoom/pan/drag.:hover { transform: translateY(-1px) }on a[fNode]— overwrites Foblex's position translate; hovered nodes snap to the viewport origin.- Any
transformortransition: transformon.f-canvas— zoom stutter. will-change: transformon[fNode]— redundant and can burn GPU memory.
For hover/focus affordances use background, border, border-color, border-radius, box-shadow, color, padding. Those are safe to animate. If you feel the urge to animate a position yourself, you are duplicating Foblex's job — use centerGroupOrNode(id, animated), setScale(scale, pivot, animated) etc. instead.
4. Use Foblex's own connection markers — never hand-roll <svg><defs><marker>
Foblex ships <f-connection-marker-arrow> and <f-connection-marker-circle> that project inside <f-connection>. They follow the theme (--ff-marker-color defaults to --ff-connection-color) and automatically participate in selection and snap states.
<f-connection [fSourceId]="..." [fTargetId]="..." class="my-edge-kind">
<f-connection-marker-arrow type="end" />
</f-connection>
For custom shapes (diamonds, triangles, anything beyond an arrow) use the fMarker directive on an inline <svg> with your own <path>. See references/api-reference.md §Connection Markers.
If you catch yourself writing <svg class="defs"><marker id="...">...</marker></svg> and marker-end: url(#id) in CSS, stop — you are reinventing the library.
5. Per-kind connection styling goes through theme tokens, not ::ng-deep
The default theme reads --ff-connection-color, --ff-connection-width, --ff-marker-color (see @foblex/flow/styles/tokens/_ff-aliases.scss). Override the tokens on a class attached to <f-connection>:
.f-conn--mentions {
--ff-connection-color: var(--sm-edge-mentions);
--ff-connection-width: 2.5px;
--ff-marker-color: var(--sm-edge-mentions);
}
CSS custom properties inherit through Angular's emulated encapsulation, so no ::ng-deep is needed for this.
When a property has no theme token (e.g. stroke-dasharray) or you need to hide something the library renders, fall back to ::ng-deep scoped to a wrapper element you own, in the view's component CSS. View-specific Foblex overrides belong in the view's stylesheet, not global src/styles.css. Canonical pattern:
/* graph-view.css */
.graph__canvas-wrap ::ng-deep .f-conn--related .f-connection-path {
stroke-dasharray: 4 3;
}
.graph__canvas-wrap bounds the reach; ::ng-deep pierces into Foblex's rendered SVG. ::ng-deep is deprecated in Angular but still the officially documented escape hatch — and Foblex's own examples use it. Use it narrowly, one rule at a time, under a wrapper class you own.
6. Foblex separates interaction from rendering — disabling behavior does NOT hide the visual
Example: <f-connection [fReassignDisabled]="true"> prevents drag-to-reassign but the theme still paints the endpoint drag-handle circles (blue rings by default, from --ff-color-accent). Read-only views must both disable the interaction via input AND suppress the visual. For suppression:
- Prefer token overrides when the theme exposes one. Drag-handle ring →
--ff-connection-drag-handle-stroke:
Custom properties inherit through Foblex's SVG, so the.graph__canvas-wrap { --ff-connection-drag-handle-stroke: transparent; }<circle>stays in the DOM (preserves the library's layout and hit-testing) but renders invisible. Zero::ng-deep. - Connector sockets (the 16×16 circles painted on every connector element (
[fConnector], or the legacyfNodeInput/fNodeOutput) by_socket-frame— blue when connected, neutral when idle, see_connector.scss) follow the same pattern. To suppress them entirely (e.g. for an "arrow only" read-only graph) override the four colour tokens at the wrapper level:
The connector elements (.graph__canvas-wrap { --ff-connector-background-color: transparent; --ff-connector-border-color: transparent; --ff-connector-connected-color: transparent; --ff-connector-node-ring-color: transparent; }[fConnector], or legacy<div fNodeInput>/<div fNodeOutput>) MUST stay in the DOM (they are the geometric anchors the connection layer reads to compute arrow endpoints — see rule 8 for the complementary positioning requirement). Painting them invisible keeps the geometry while removing the visual noise. - When no token exists, override
fill/strokedirectly via::ng-deepscoped to a wrapper you own. Preferfill: transparent/stroke: transparentoverdisplay: none— the library often depends on the element existing for internal calculations.
7. ::ng-deep is Foblex's documented escape hatch, not a hack
Foblex's own reference examples (e.g. apps/example-apps/uml-diagram in Foblex/f-flow) style connections, markers, drag handles and minimaps with ::ng-deep <component-host-tag> { ... }. It is deprecated in Angular but still functional and is the documented path when a theme token does not exist. Rules of use in this repo:
- Prefer token overrides (rule 5) whenever the property has one.
- When
::ng-deepis the only option, scope it under the view's component host or a wrapper class you own. - Keep each rule narrow — single concern, minimal properties.
- The rule lives in the view's component CSS, NOT in
src/styles.css. Globals are for rules that are genuinely app-wide.
8. Connector sub-elements need explicit positioning — directives don't add orientation classes
Connector directives apply fixed host classes ([fConnector]: f-component, f-connector, plus role classes f-connector-source / f-connector-target / f-connector-source-target / f-connector-outlet; legacy directives: f-node-input, f-node-output) plus state classes (f-connector-multiple, f-connector-disabled, f-connector-connectable, legacy f-node-input-connected, etc. — see fesm2022/foblex-flow.mjs host bindings). What they do NOT do is translate fConnectorConnectableSide (legacy fInputConnectableSide / fOutputConnectableSide) into orientation classes like .top / .bottom / .left / .right. Those orientation classes exist in _socket-frame (@foblex/flow/styles/domains/_connector.scss) as &.top { top: calc(var(--ff-connector-size) / -2); ... } etc., but the SCSS only fires when you put the class on the element manually.
The default theme applies position: absolute plus a 16×16 size to every connector socket via _socket-frame. Without an explicit top / right / bottom / left from your CSS, position: absolute defaults to the upper-left corner of the nearest positioned ancestor — i.e. the connector renders at the card's top-left corner, and the connection's arrow follows it there. Symptom: arrows appear "off to one side" of the card or partially behind it; nodes look fine until you eyeball where edges actually terminate.
This bites only when connectors are sub-elements of the node card. Two layout shapes:
Sub-element pattern (two connector elements inside the card; ids stay distinct because the unified registry rejects duplicates):
<div fNode [fNodeId]="node.id" class="sm-gnode">
<div fConnector
fConnectorType="target"
[fConnectorId]="node.id + '-in'"
[fConnectorConnectableSide]="'top'"
class="sm-gnode__connector sm-gnode__connector--in"></div>
<span>{{ node.label }}</span>
<div fConnector
fConnectorType="source"
[fConnectorId]="node.id + '-out'"
[fConnectorConnectableSide]="'bottom'"
class="sm-gnode__connector sm-gnode__connector--out"></div>
</div>
.sm-gnode { position: relative; /* …content… */ }
.sm-gnode__connector { position: absolute; pointer-events: none; }
.sm-gnode__connector--in {
top: calc(var(--ff-connector-size) / -2);
left: 50%;
transform: translateX(-50%);
}
.sm-gnode__connector--out {
bottom: calc(var(--ff-connector-size) / -2);
left: 50%;
transform: translateX(-50%);
}
The calc(var(--ff-connector-size) / -2) matches the math _socket-frame uses internally for &.top / &.bottom, so the socket centers exactly on the card's edge regardless of theme overrides to --ff-connector-size.
Same-element pattern (skill-map today, and every official example — bracket, call-center, uml-diagram):
<div class="sm-gnode-host"
fNode [fNodeId]="node.id"
fConnector fConnectorType="source-target" [fConnectorId]="node.id">
…
</div>
When the directive sits on the card itself, the card IS the connector — connection geometry anchors to the card's edges naturally and no extra positioning is needed. This is skill-map's current shape (rule 1); the v18-era official examples do the same thing with the legacy pair (fNodeInput [fInputId]="m.id" plus fNodeOutput [fOutputId]="m.id" on one element, which the unified source-target type folds into a single directive). The sub-element trap above only matters when you split connectors out into child elements.
If you're tempted to delete CSS rules positioning connector sub-elements ([fConnector], legacy [fNodeInput] / [fNodeOutput]) because "Foblex's _socket-frame covers it" — stop. The socket is positioned absolute but with no offsets; you own the offsets.
9. Foblex's drag directives consume pointerup — use mouseup for drag-end detection
fDragHandle and fDraggable capture the pointer for the drag lifecycle (likely via setPointerCapture + propagation handling). A pointerup listener registered on document — even with { capture: true, once: true } and a queueMicrotask defer — does NOT reliably fire when a node drag ends. The event is consumed or rerouted internally before reaching the handler.
Symptom: you wire a pointerdown → pointerup pair on document to detect "drag ended" (e.g. to flush a buffer or persist final positions to localStorage), it works in isolation but never fires after a [fNode] drag. State silently never updates.
Fix: listen on mouseup instead. The browser fires both pointer and mouse events for the same physical interaction; Foblex intercepts pointer events but not mouse events. The middle-mouse pan in graph-view.ts (onCanvasMouseDown → document.addEventListener('mouseup', …)) has used this approach since day one — that was the hint.
onNodePointerDown(event: PointerEvent): void {
this.pointerDownAt = { x: event.clientX, y: event.clientY };
document.addEventListener('mouseup', this.onNodeMouseUp, { once: true });
}
private dragInProgress = false;
private dragBuffer: TNodePositions | null = null;
private readonly void => {
// Defer one microtask so any final fNodePositionChange Foblex emits
// synchronously around the up event lands in the buffer first.
queueMicrotask(() => {
if (!this.dragInProgress) { this.dragBuffer = null; return; }
this.dragInProgress = false;
if (this.dragBuffer) this.nodePositions.set(this.dragBuffer);
this.dragBuffer = null;
writeStoredNodePositions(this.nodePositions());
});
};
onNodePositionChange(id: string, position: IPoint): void {
// Buffer in a non-signal field. Writing the signal here would
// invalidate the `graph` computed and force a full @for diff over
// nodes/edges 60–120×/sec for nothing — Foblex already updates the
// dragged node's DOM transform internally during drag.
if (!this.dragBuffer) this.dragBuffer = { ...this.nodePositions() };
this.dragBuffer[id] = { x: position.x, y: position.y };
this.dragInProgress = true;
}
Two reinforcing perf wins land in this pattern, both hidden by a 120 fps rAF reading:
- Single signal write at drag-end — not 60–120/sec during drag. Eliminates the
graphcomputed invalidation cascade through the @for over nodes and edges. - Single localStorage I/O at drag-end — sync
setItemcalls during drag pile up as 1–5 ms stalls each (more on slow disks). The avg fps stays high but every frame during drag has a stall, producing perceivable jank.
If you reach for pointerup thinking it is the "modern pointer event API", read this rule first.
Antipattern checklist
If you catch yourself typing any of these, stop and re-read the rule in parentheses:
<svg ...><marker id="..."— use<f-connection-marker-arrow>(rule 4)marker-end: url(#...)in CSS — use<f-connection-marker-arrow>(rule 4)::ng-deep .f-connection-path { stroke: ...; stroke-width: ... }— override--ff-connection-color/--ff-connection-widthon the<f-connection>class (rule 5)::ng-deep .f-canvaswith any rule — almost certainly wrong; the canvas transform is library-controlled (rule 3)transition: transform ...ortransform: ...on[fNode]/.f-canvas(rule 3)display: noneon a library-rendered element — usefill: transparent/stroke: transparentinstead (rule 6)- View-specific Foblex CSS in
src/styles.css— move to the view's component CSS (rules 5 and 7) - Custom class names prefixed with
f-— that prefix is reserved by Foblex. Our nodes usesm-gnodefor this reason. Pick a project prefix (sm-) for your own classes - Restoring a viewport with
canvas.setPosition(...)+canvas.setScale(...)— use the[position]/[scale]input bindings on<f-canvas>instead (see "Persisted viewport" pattern) - Binding
[position]/[scale]to a constant (field-init literal,readonlyvalue that never reassigns) — Foblex re-evaluates the inputs on every CD pass and reconciles against its internal viewport, so any user pan / zoom gets undone the next time the host re-renders. Bind to a signal that(fCanvasChange)writes (see "Persisted viewport" pattern) - Redeclaring
--ff-color-*inside your own.app-dark { ... }block — the package already ships dark defaults under.dark/[data-theme='dark']; toggle that class on the document root from your theme service instead (rule 2, "Dark mode") - Deleting your own
position: absolute; top/bottom: ...rules from connector sub-elements because "Foblex's_socket-framealready handles it" — it setsposition: absoluteand a 16×16 size, but no offsets; you own the offsets when connectors are sub-elements (rule 8) - Expecting
fConnectorConnectableSide(or the legacyfInputConnectableSide/fOutputConnectableSide) to add.top/.bottom/.left/.rightclasses automatically — they don't; the directive only stores the side as metadata, the orientation classes are SCSS sub-classes you place yourself (rule 8) - Writing
fNodeInput/fNodeOutput/fNodeOutlet(orfInputId/fOutputIdon<f-connection>) in new code: deprecated legacy since v19; use[fConnector]+fConnectorIdandfSourceId/fTargetId(rule 1) - Binding sides on both the connector AND the connection:
fConnectorConnectableSideon[fConnector]andfSourceSide/fTargetSideon<f-connection>compete for the same decision; pick ONE level. skill-map binds at the connection level only ([fSourceSide]="outputSide()"/[fTargetSide]="inputSide()"), connector-level sides stay unset - Writing
selectedNodeId.set(...)directly from a click handler, deep link, or effect: every programmatic selection write routes throughapplySelection(id)so Foblex's internal selection, the.f-selectedpaint, and the keyboard layer's active item stay in sync (see "Selection single-owner contract") - Wrapping the
<div fNode>inner DOM in a shared<ng-template>and projecting it with<ng-container *ngTemplateOutlet>for DRY-ness — Foblex's content queries on[fNode]don't reach into embedded views, so connectors disappear and every node renders at(0,0)in a redraw loop. Duplicate the markup in each branch instead (see "Performance levers from the stress-test example") - Adding
<f-background>and seeing the grid only at the edges (centre is solid colour) —<f-canvas>paints--ff-canvas-background-coloropaque on top of the background layer. Override it totransparentat your wrapper (see "Background grid" canonical pattern) - Painting an
:hover/:focusoutline usingborder-colorchange on[fNode]cards while a sibling-class state (.sm-gnode--selected/.sm-gnode--highlighted) sets the same property — the cascade fights between user gestures and selection state. Keep gesture state on a different property (box-shadow) so the two layers compose instead of conflict document.addEventListener('pointerup', …)to detect the end of a[fNode]drag —fDragHandleconsumes pointerup; the listener never fires. Usemouseupinstead (rule 9)- Mirroring every
(fSelectionChange)into app state unconditionally:SelectByPointerselects on pointerdown, so grabbing a node to move it reports a selection at drag start and pops the inspector open mid-drag. Reject the event whilef-draggingis on the flow host, and re-assert the app selection at themouseupflush (see "Drag is not a click") - Guarding only the app's
(click)handler with a drag-distance check and assuming drags can no longer select, the library's ownfSelectionChangenever passes through that handler - Writing to a signal that feeds the
graphcomputed (typicallynodePositions) on every(fNodePositionChange)— invalidates the @for over nodes/edges 60–120×/sec. Buffer the position in a non-signal field and flush once atmouseup(rule 9) - Calling
localStorage.setItem(or any sync I/O) from inside(fNodePositionChange)— sync writes during drag stall the main thread per frame and produce visible jank even when rAF reads 120 fps. Defer to themouseupflush (rule 9)
Canonical patterns
Read-only graph (no editing, no reassign, no selection)
<f-flow fDraggable>
<f-canvas fZoom [fZoomStep]="0.06" [fZoomDblClickStep]="0.35">
<f-connection
[fSourceId]="edge.from"
[fTargetId]="edge.to"
[fSourceSide]="outputSide()"
[fTargetSide]="inputSide()"
fType="segment"
fBehavior="fixed"
[fReassignDisabled]="true"
[fSelectionDisabled]="true"
[class]="'f-conn--' + edge.kind"
>
<f-connection-marker-arrow type="end" />
</f-connection>
<!-- nodes carry: fConnector fConnectorType="source-target" [fConnectorId]="node.id" -->
</f-canvas>
</f-flow>
Endpoint ids are plain node ids matched against fConnectorId (rule 1). Sides are bound at the connection level only (fSourceSide / fTargetSide, type EFConnectionConnectableSide, driven here by the layout-direction signals); connector-level fConnectorConnectableSide stays unset so the two levels never fight.
.graph__canvas-wrap {
/* Token overrides: hide reassign rings, let library keep the DOM intact */
--ff-connection-drag-handle-stroke: transparent;
}
.f-conn--mentions {
--ff-connection-color: var(--sm-edge-mentions);
--ff-connection-width: 2.5px;
--ff-marker-color: var(--sm-edge-mentions);
}
/* stroke-dasharray has no token — scoped ::ng-deep, component CSS only */
.graph__canvas-wrap ::ng-deep .f-conn--related .f-connection-path {
stroke-dasharray: 4 3;
}
Persisted viewport (localStorage / restore across reloads)
Restoring pan position and zoom is the ONE place where the intuitive imperative API (setPosition + setScale) produces silent, visually broken output. Symptoms: arrows missing on first paint, nodes rendered at an offset, and both only reconciling after the first user pan/zoom. Reason: the calls update the transform model out of phase with the connection measurement pass, so the SVG connection layer renders against stale geometry.
Use the [position] and [scale] input bindings on <f-canvas> instead. This is the pattern the official libs/f-examples/advanced/undo-redo uses — Foblex applies the transform once, atomically, on the first render.
Critical: bind to signals, NOT to field-initialized literals. Foblex re-evaluates [position] / [scale] on every change-detection pass; if the bound value drifts from the canvas's internal viewport (e.g. user pans → internal viewport moves; the bound literal stays at its boot value), Foblex re-applies the bound value to "reconcile" and snaps the canvas back to the boot position. Symptom: every re-render of the host (a WebSocket-driven nodes refresh, a filter toggle, anything that invalidates a parent computed) snaps the viewport back to wherever it was when the component mounted. Storing the viewport in signals that (fCanvasChange) writes keeps the binding always in sync with the canvas's own state, so reconciliation is a no-op.
A reproducible test for the bug: start the app, pan the canvas a bit, then trigger any state change that re-runs change detection on the host (e.g. WS push, filter toggle). If the canvas snaps back, the bindings are constants instead of signals — F5 "fixes" it because the field initializer re-runs and picks up the panned position from localStorage, which masks the underlying defect.
private readonly savedViewport = readStoredViewport(); // localStorage parse
// Signals — NOT field-init constants. (fCanvasChange) writes them on
// every pan / zoom so the binding stays in sync with the canvas's
// internal viewport, neutralising Foblex's reconcile-on-CD behaviour.
protected readonly viewportPosition = signal<IPoint>(
this.savedViewport
? { x: this.savedViewport.x, y: this.savedViewport.y }
: { x: 0, y: 0 },
);
protected readonly viewportScale = signal<number>(this.savedViewport?.scale ?? 1);
private hasCompletedInitialLayout = false;
constructor() {
effect(() => {
const data = this.graph();
if (data.nodes.length === 0) return;
queueMicrotask(() => {
if (this.hasCompletedInitialLayout) {
this.canvas()?.fitToScreen({ x: 40, y: 40 }, false); // filter-driven refit
return;
}
this.hasCompletedInitialLayout = true;
// If a viewport was restored, the [position] / [scale] bindings already
// placed the canvas. Only auto-fit on a clean slate.
if (!this.savedViewport) this.canvas()?.fitToScreen({ x: 40, y: 40 }, false);
});
});
}
onCanvasChange(event: FCanvasChangeEvent): void {
// Mirror the canvas's internal viewport into our bound signals so a
// future change-detection pass doesn't reconcile and snap back.
this.viewportPosition.set({ x: event.position.x, y: event.position.y });
this.viewportScale.set(event.scale);
if (!this.hasCompletedInitialLayout) return;
localStorage.setItem(KEY, JSON.stringify({
x: event.position.x, y: event.position.y, scale: event.scale,
}));
}
<f-canvas
fZoom
[position]="viewportPosition()"
[scale]="viewportScale()"
[debounceTime]="150"
(fCanvasChange)="onCanvasChange($event)"
>
Notes:
- Initialise the signals in a field initializer (not after
ngOnInit) — the binding is evaluated on first template pass. (fCanvasChange)MUST write back into the signals. That is what keeps the bound value in sync with the canvas. Skipping the write is the bug.- The
hasCompletedInitialLayoutguard is essential. Without it, the first auto-firedfCanvasChange(triggered by the initial binding) overwrites storage with{0,0,1}before the user has touched anything. - Never mix two sources of truth for the SAME axis on one mount: position has only one public path anyway (the
[position]binding, see below); for scale, pick the[scale]binding ORsetScale(), not both. - Foblex 18.6 removed the public
setPosition(it is now an internal_setPosition), so there is NO imperative pan setter left: EVERY position change goes through the[position]signal binding, the restore path AND post-mount interactions like a middle-mouse pan. Drive the pan by writing the sameviewportPositionsignal[position]is bound to, Foblex applies the transform and redraws on the input change (no manualredraw()), which is exactly whatmiddle-mouse-pan.tsdoes.setScale()stays public for post-mount zoom (wheel / pinch / buttons), andgetPosition()/getScale()still read the live viewport.
Selection single-owner contract + node/edge highlighting (click or arrows → light up neighbours)
The highlight/dim mechanics were lifted from apps/example-apps/tournament-bracket in Foblex/f-flow, but the ownership stance changed with v19: Foblex owns selection now. (The old skill-map shape where the app owned selection exclusively and Foblex never saw it is gone; with the keyboard layer installed, Foblex's internal selection drives the .f-selected paint and the keyboard focus, so a divergent app-only signal would desync them.)
The bridge (see graph-view.ts, applySelection / onFlowSelectionChange for the authoritative wording): every PROGRAMMATIC selection write (click handler, isolate, deep links, escape/background deselect, the filter guard) goes through one helper that sets the app signal AND pushes into Foblex; user gestures (click, arrow keys, Shift+area rectangle, Ctrl/Cmd+A) flow the other way, Foblex mutates its own selection and reports through (fSelectionChange). Writes are idempotent, so the two paths converging on the same id is harmless.
private applySelection(id: string | null): void {
this.selectedNodeId.set(id);
this.flow()?.select(id === null ? [] : [id], [], false);
}
// Foblex → app bridge. Exactly one selected node drives the inspector /
// highlight state; empty and multi-node selections (Shift+area
// rectangle, Ctrl/Cmd+A) both map to "no inspected node".
protected onFlowSelectionChange(event: FSelectionChangeEvent): void {
if (isFlowDragging(this.flow()?.hostElement)) return; // see "Drag is not a click" below
const ids = event.fNodeIds;
this.selectedNodeId.set(ids.length === 1 ? (ids[0] ?? null) : null);
}
Wire (fSelectionChange)="onFlowSelectionChange($event)" on <f-flow fDraggable>. The third argument of FFlowComponent.select(nodes, connections, isSelectedChanged) is false so the programmatic write does not re-emit fSelectionChange (that would loop the bridge). The event payload is FSelectionChangeEvent with fNodeIds / fGroupIds / fConnectionIds (in v19 these are aliases of the canonical nodeIds / groupIds / connectionIds fields).
Derived state (an adjacency map computed from the graph):
readonly selectedNodeId = signal<string | null>(null);
private readonly adjacency = computed<Map<string, Set<string>>>(() => {
const map = new Map<string, Set<string>>();
for (const edge of this.graph().edges) {
if (!map.has(edge.from)) map.set(edge.from, new Set());
if (!map.has(edge.to)) map.set(edge.to, new Set());
map.get(edge.from)!.add(edge.to);
map.get(edge.to)!.add(edge.from);
}
return map;
});
Pure helpers (no side effects, drive the template classes):
isSelected(id: string) { return this.selectedNodeId() === id; }
isHighlighted(id: string) { /* neighbour of selected */ }
isDimmed(id: string) { /* selected exists, this is neither selected nor neighbour */ }
isEdgeHighlighted(e) { /* one endpoint matches selected */ }
isEdgeDimmed(e) { /* selected exists, neither endpoint matches */ }
Template: project edges and nodes through ngProjectAs="[fConnections]" / ngProjectAs="[fNodes]", bind isSelected / isHighlighted / isDimmed to sm-gnode--* classes on each <div fNode> (plus (click)="selectNode(...)" / (dblclick)="openNode(...)") and isEdgeHighlighted / isEdgeDimmed to f-conn--* classes on each <f-connection>. Style: --selected / --highlighted via border-color + box-shadow; --dimmed via host opacity (cascades to the SVG path, no ::ng-deep); .f-conn--highlighted { --ff-connection-width: 3px } declared after the per-kind selectors so source order wins.
Notes:
- Edge dim via host opacity, not stroke alpha. The
<f-connection>host has emulated encapsulation butopacitycascades to the SVG path it renders. No::ng-deep. Same trick the bracket SCSS uses. - Deselect by listening for
(click)on a wrapper around the canvas and ignoring clicks whoseevent.target.closest('.sm-gnode')(or any other interactive overlay) is non-null. Foblex's<f-flow>does not expose a "background-only click" event. The deselect itself isapplySelection(null), never a bare signal write. Two guards are mandatory on this handler: (a) a drag-distance check against the wrapper's ownmousedownanchor, because the browser synthesizes aclickat the END of any background drag (down and up both land on the canvas), so releasing a Shift+marquee would otherwise wipe the selection it just built; (b) skip whenshiftKey/ctrlKey/metaKeyis held, those are selection-BUILDING gestures (additive marquee,SelectByPointertoggle) that Foblex answers by keeping its selection, and clearing on its behalf fights the library. - Single click selects, double click navigates to the inspector. Same gesture as Finder / file managers; descoverable. Maintain a small drag-distance guard in the click handler so a node-drag doesn't fire
selectNode. - Selection guard via effect: when filters change and the selected node is no longer visible, clear the selection (
effect(() => { if (!this.graph().nodes.some(n => n.id === id)) this.applySelection(null); })). Avoids dangling highlight state on both sides of the bridge.
Drag is not a click: the bridge must reject drag-induced selections.
SelectByPointer runs in Foblex's _pointerDownClaimants, so grabbing a node to MOVE it already selects it, on pointerdown, before anyone knows a drag is coming. The selection is then reported the instant the drag threshold is crossed (EmitStartDragSequenceEvent → EmitSelectionChangeEventRequest). A bridge that mirrors every fSelectionChange therefore pops the inspector open mid-drag, on a gesture that never meant "inspect this". A drag-distance guard on the (click) handler does NOT save you: the guard only covers the app's own handler, the library's event bypasses it entirely.
The only order-safe discriminator is the f-dragging host class (F_CSS_CLASS.DRAG_AND_DROP.DRAGGING, the same class Foblex's :host(.f-dragging) styles read). EmitStartDragSequenceEvent stamps it one statement BEFORE emitting the selection change, so it is already on the <f-flow> host when the handler runs. Everything else is too late: (fDragStarted) is emitted AFTER the selection event, and the position stream (fNodePositionChange) only starts on the following onPointerMove.
export function isFlowDragging(host: HTMLElement | null | undefined): boolean {
return host?.classList.contains(F_CSS_CLASS.DRAG_AND_DROP.DRAGGING) === true;
}
Suppressing the mirror is only half the fix: Foblex's internal selection now holds the dragged node (painted .f-selected) while the app still inspects another one. Re-assert the app's selection when the drag settles, applySelection(this.selectedNodeId()), from the rule 9 mouseup flush. That moment is safe to mutate library state from: Foblex finalizes its drag on pointerup, which the browser fires before mouseup, so the whole finalize + reset() pipeline has already run. Do NOT hang the re-assert on (fDragEnded) instead: it fires for every drag kind, and a Shift+area marquee applies its selection in SelectionAreaFinalize before that event, so a blanket re-assert would wipe the marquee.
The re-assert itself must also be multi-selection aware: when the settled drag moved a multi-node selection (marquee or Ctrl/Cmd+click set; grabbing an already-selected node keeps the whole set and drags the group), Foblex's selection IS the user's intent, and pushing the app's single id over it would drop the group at release. Skip the re-assert when flow.getSelection().fNodeIds.length > 1; the single-node divergence it exists to fix cannot occur in that case. Related gesture handlers need the same courtesy: the app's own node (click) select must not run when a multi-select modifier (Shift/Ctrl/Cmd) is held, or it collapses the set the library just
…(truncated)