feat(docs): stop hiding half the component API — typed emits, exposes, flow guide
Publish to NPM / Check version changes and publish (push) Has been cancelled
Publish to NPM / Check version changes and publish (push) Has been cancelled
The per-part regex only saw defineEmits<{ inline literal }>(), so a named
interface — how Flow, Popover, Dialog, Menu and Drawer all declare their
events — extracted as zero emits, and defineExpose was not extracted at
all: 36 components' template-ref surfaces were invisible. Consumers
rebuilt what existed (nodeDragStop from @nodes-change, a renderless
child to reach fitView).
- extractor: one type-checking project per components package with a
virtual <file>.vue.ts mirror per SFC, so cross-file emits interfaces
(extends included) and expose spreads resolve through the checker —
...api expands into the composable's full return with its JSDoc
- parts gain exposes; emits/exposes members carry their JSDoc text;
update:* model emits get a stock description
- UI: Description column on emits, an Exposes (template ref) table; MCP
get_doc renders the same
- FlowRoot: JSDoc on every emit and exposed member
- new primitives guide page: building flow graphs — sizing, custom
nodes, .nodrag, click family, instance API, edge labels
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,12 @@ const roleColor: Record<string, string> = {
|
|||||||
<DocsEmitsTable :emits="part.emits" />
|
<DocsEmitsTable :emits="part.emits" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
|
<div v-if="part.exposes?.length" class="mb-3">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Exposes (template ref)</div>
|
||||||
|
<DocsExposesTable :exposes="part.exposes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="part.props.length === 0 && part.emits.length === 0 && !part.exposes?.length" class="text-sm text-fg-subtle italic">
|
||||||
No props or events — renders its element and forwards attributes.
|
No props or events — renders its element and forwards attributes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ defineProps<{
|
|||||||
<tr class="bg-bg-subtle text-left">
|
<tr class="bg-bg-subtle text-left">
|
||||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
|
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
|
||||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
|
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
|
||||||
|
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -22,6 +23,10 @@ defineProps<{
|
|||||||
<td class="py-2.5 px-4">
|
<td class="py-2.5 px-4">
|
||||||
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
|
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-2.5 px-4 text-fg-muted min-w-48">
|
||||||
|
<DocsText v-if="e.description" :text="e.description" />
|
||||||
|
<span v-else>—</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">import type { PropertyMeta } from '../../modules/extractor/types';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
exposes: PropertyMeta[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="exposes.length > 0" class="overflow-x-auto rounded-xl border border-border">
|
||||||
|
<table class="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-bg-subtle text-left">
|
||||||
|
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Name</th>
|
||||||
|
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
|
||||||
|
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="x in exposes" :key="x.name" class="border-t border-border align-top">
|
||||||
|
<td class="py-2.5 px-4 whitespace-nowrap">
|
||||||
|
<code class="text-accent-text font-mono text-[13px] font-medium">{{ x.name }}</code><span v-if="x.optional" class="text-fg-subtle text-xs">?</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-4">
|
||||||
|
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ x.type }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-4 text-fg-muted min-w-48">
|
||||||
|
<DocsText v-if="x.description" :text="x.description" />
|
||||||
|
<span v-else>—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import { basename, dirname, relative, resolve } from 'node:path';
|
import { basename, dirname, relative, resolve } from 'node:path';
|
||||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
import { Node, Project, SyntaxKind } from 'ts-morph';
|
import { Node, Project, SyntaxKind, ts } from 'ts-morph';
|
||||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
||||||
import type {
|
import type {
|
||||||
CategoryMeta,
|
CategoryMeta,
|
||||||
@@ -858,6 +858,144 @@ function extractScriptBlock(sfc: string, setup: boolean): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SFC type project ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One type-checking project per components package: every real `src/**\/*.ts`
|
||||||
|
* file plus, for each SFC part, a virtual `<file>.vue.ts` mirror holding its
|
||||||
|
* two script blocks. TS resolves a `./X.vue` specifier by appending `.ts`, so
|
||||||
|
* the mirrors make cross-file shapes resolve for real — `defineEmits<XEmits>()`
|
||||||
|
* where the interface lives in another block, a sibling `.ts` or another SFC,
|
||||||
|
* and `defineExpose({ ...api })` where the spread's type is a composable's
|
||||||
|
* return. The per-part regexes never saw any of those, which is exactly how
|
||||||
|
* half of Flow's API ended up invisible in the docs.
|
||||||
|
*/
|
||||||
|
function buildSfcProject(pkgDir: string): Project {
|
||||||
|
const srcDir = resolve(pkgDir, 'src');
|
||||||
|
const tsconfigPath = resolve(pkgDir, 'tsconfig.json');
|
||||||
|
const project = new Project({
|
||||||
|
tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
|
||||||
|
skipAddingFilesFromTsConfig: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
project.addSourceFilesAtPaths([`${srcDir}/**/*.ts`, `!${srcDir}/**/__test__/**`]);
|
||||||
|
|
||||||
|
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.vue') || entry.name === 'demo.vue') continue;
|
||||||
|
|
||||||
|
const full = resolve(entry.parentPath, entry.name);
|
||||||
|
if (full.includes('__test__')) continue;
|
||||||
|
|
||||||
|
const sfc = readFileSync(full, 'utf-8');
|
||||||
|
const script = `${extractScriptBlock(sfc, false)}\n${extractScriptBlock(sfc, true)}`;
|
||||||
|
if (script.trim()) project.createSourceFile(`${full}.ts`, script, { overwrite: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type display: keep alias names (`Ref<T>`, not its expansion), never truncate. */
|
||||||
|
const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.NoTruncation;
|
||||||
|
|
||||||
|
/** JSDoc description of a declaration; a const's doc sits on its statement. */
|
||||||
|
function describeDecl(node: Node | undefined): string {
|
||||||
|
if (!node) return '';
|
||||||
|
const holder = Node.isVariableDeclaration(node) ? node.getVariableStatement() ?? node : node;
|
||||||
|
if (!Node.isJSDocable(holder)) return '';
|
||||||
|
const jsdocs = holder.getJsDocs();
|
||||||
|
return getDescription(jsdocs, getJsDocTags(jsdocs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits through the checker's view of `defineEmits<T>()`: the inline literal
|
||||||
|
* AND a named interface (same block, sibling `.ts`, another SFC via the
|
||||||
|
* mirrors), `extends` chains included — with each member's JSDoc.
|
||||||
|
*/
|
||||||
|
function extractEmitsFrom(sf: SourceFile): EmitMeta[] {
|
||||||
|
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
|
||||||
|
.find(c => c.getExpression().getText() === 'defineEmits');
|
||||||
|
const typeArg = call?.getTypeArguments()[0];
|
||||||
|
if (!call || !typeArg) return [];
|
||||||
|
|
||||||
|
const emits: EmitMeta[] = [];
|
||||||
|
for (const prop of typeArg.getType().getProperties()) {
|
||||||
|
const decl = prop.getDeclarations()[0];
|
||||||
|
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
|
||||||
|
|
||||||
|
emits.push({
|
||||||
|
name: prop.getName(),
|
||||||
|
payload: cleanType(written ?? prop.getTypeAtLocation(call).getText(call, TYPE_TEXT_FLAGS)),
|
||||||
|
description: describeDecl(decl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return emits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `defineExpose({ … })` → the template-ref surface. Spreads expand through the
|
||||||
|
* checker (`...api` lists every member of the composable's return type with its
|
||||||
|
* JSDoc), so the docs show the full instance API instead of nothing at all.
|
||||||
|
*/
|
||||||
|
function extractExposesFrom(sf: SourceFile): PropertyMeta[] {
|
||||||
|
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
|
||||||
|
.find(c => c.getExpression().getText() === 'defineExpose');
|
||||||
|
const arg = call?.getArguments()[0];
|
||||||
|
if (!arg || !Node.isObjectLiteralExpression(arg)) return [];
|
||||||
|
|
||||||
|
const out: PropertyMeta[] = [];
|
||||||
|
const push = (name: string, type: string, description: string, optional = false) => {
|
||||||
|
if (!out.some(p => p.name === name))
|
||||||
|
out.push({ name, type: cleanType(type), description, optional, defaultValue: null, readonly: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const member of arg.getProperties()) {
|
||||||
|
if (Node.isSpreadAssignment(member)) {
|
||||||
|
const spreadType = member.getExpression().getType();
|
||||||
|
const props = spreadType.getProperties();
|
||||||
|
|
||||||
|
// Unresolvable spread — surface it verbatim rather than dropping it.
|
||||||
|
if (spreadType.isAny() || props.length === 0) {
|
||||||
|
push(member.getText(), '', '');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prop of props) {
|
||||||
|
const decl = prop.getDeclarations()[0];
|
||||||
|
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
|
||||||
|
push(
|
||||||
|
prop.getName(),
|
||||||
|
written ?? prop.getTypeAtLocation(member).getText(member, TYPE_TEXT_FLAGS),
|
||||||
|
describeDecl(decl),
|
||||||
|
decl !== undefined && Node.isQuestionTokenable(decl) && decl.hasQuestionToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Node.isShorthandPropertyAssignment(member)) {
|
||||||
|
const local = sf.getProject().getTypeChecker().getShorthandAssignmentValueSymbol(member);
|
||||||
|
push(
|
||||||
|
member.getName(),
|
||||||
|
member.getType().getText(member, TYPE_TEXT_FLAGS),
|
||||||
|
describeDecl(local?.getDeclarations()[0]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (Node.isPropertyAssignment(member)) {
|
||||||
|
const init = member.getInitializer();
|
||||||
|
const initDecl = init && Node.isIdentifier(init) ? init.getSymbol()?.getDeclarations()[0] : undefined;
|
||||||
|
push(
|
||||||
|
member.getName().replaceAll(/^['"]|['"]$/g, ''),
|
||||||
|
(init ?? member).getType().getText(member, TYPE_TEXT_FLAGS),
|
||||||
|
describeDecl(member) || describeDecl(initDecl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (Node.isMethodDeclaration(member)) {
|
||||||
|
push(member.getName(), member.getType().getText(member, TYPE_TEXT_FLAGS), describeDecl(member));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
|
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
|
||||||
function extractEmits(setupScript: string): EmitMeta[] {
|
function extractEmits(setupScript: string): EmitMeta[] {
|
||||||
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
|
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
|
||||||
@@ -907,7 +1045,7 @@ function extractModels(setupScript: string): { props: PropertyMeta[]; emits: Emi
|
|||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
});
|
});
|
||||||
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
|
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: `Emitted when \`v-model${name === 'modelValue' ? '' : `:${name}`}\` updates.` });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { props, emits };
|
return { props, emits };
|
||||||
@@ -968,7 +1106,7 @@ function roleFromName(componentName: string, base: string): string {
|
|||||||
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
|
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
|
||||||
* is the package subpath (e.g. `./forms/checkbox`).
|
* is the package subpath (e.g. `./forms/checkbox`).
|
||||||
*/
|
*/
|
||||||
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
|
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string, sfcProject?: Project): ComponentMeta | null {
|
||||||
// A component group is any dir that ships at least one .vue file.
|
// A component group is any dir that ships at least one .vue file.
|
||||||
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
|
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
|
||||||
if (vueFiles.length === 0) return null;
|
if (vueFiles.length === 0) return null;
|
||||||
@@ -1001,16 +1139,22 @@ function buildComponentAt(dir: string, slug: string, category: string, entryPoin
|
|||||||
const role = roleFromName(name, base);
|
const role = roleFromName(name, base);
|
||||||
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
||||||
|
|
||||||
|
// Emits/exposes come from the typed SFC project when it has this part;
|
||||||
|
// the regex parser stays as the fallback for inline-literal emits.
|
||||||
|
const virtual = sfcProject?.getSourceFile(`${resolve(dir, file)}.ts`);
|
||||||
|
let emits = virtual ? extractEmitsFrom(virtual) : [];
|
||||||
|
if (emits.length === 0) emits = extractEmits(setup);
|
||||||
|
const exposes = virtual ? extractExposesFrom(virtual) : [];
|
||||||
|
|
||||||
// Merge in `defineModel` v-model props/emits (invisible to the interface/
|
// Merge in `defineModel` v-model props/emits (invisible to the interface/
|
||||||
// defineEmits parsers), de-duping against any explicitly-declared ones.
|
// defineEmits parsers), de-duping against any explicitly-declared ones.
|
||||||
const models = extractModels(setup);
|
const models = extractModels(setup);
|
||||||
const emits = extractEmits(setup);
|
|
||||||
for (const mp of models.props)
|
for (const mp of models.props)
|
||||||
if (!props.some(p => p.name === mp.name)) props.push(mp);
|
if (!props.some(p => p.name === mp.name)) props.push(mp);
|
||||||
for (const me of models.emits)
|
for (const me of models.emits)
|
||||||
if (!emits.some(e => e.name === me.name)) emits.push(me);
|
if (!emits.some(e => e.name === me.name)) emits.push(me);
|
||||||
|
|
||||||
parts.push({ name, role, description, props, emits });
|
parts.push({ name, role, description, props, emits, exposes });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1030,6 +1174,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
|||||||
const srcDir = resolve(pkgDir, 'src');
|
const srcDir = resolve(pkgDir, 'src');
|
||||||
if (!existsSync(srcDir)) return [];
|
if (!existsSync(srcDir)) return [];
|
||||||
|
|
||||||
|
const sfcProject = buildSfcProject(pkgDir);
|
||||||
const components: ComponentMeta[] = [];
|
const components: ComponentMeta[] = [];
|
||||||
|
|
||||||
// Components live one level deep, in category folders: src/<category>/<component>/.
|
// Components live one level deep, in category folders: src/<category>/<component>/.
|
||||||
@@ -1048,13 +1193,14 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
|||||||
compEntry.name,
|
compEntry.name,
|
||||||
label,
|
label,
|
||||||
`./${catEntry.name}/${compEntry.name}`,
|
`./${catEntry.name}/${compEntry.name}`,
|
||||||
|
sfcProject,
|
||||||
);
|
);
|
||||||
if (c) components.push(c);
|
if (c) components.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Backward-compat: a flat component dir directly under src.
|
// Backward-compat: a flat component dir directly under src.
|
||||||
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
|
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`, sfcProject);
|
||||||
if (c) components.push(c);
|
if (c) components.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ export interface ComponentPartMeta {
|
|||||||
props: PropertyMeta[];
|
props: PropertyMeta[];
|
||||||
/** Emitted events parsed from `defineEmits` */
|
/** Emitted events parsed from `defineEmits` */
|
||||||
emits: EmitMeta[];
|
emits: EmitMeta[];
|
||||||
|
/**
|
||||||
|
* The template-ref surface parsed from `defineExpose`, spreads expanded
|
||||||
|
* through the type checker (`...api` lists the composable's whole return).
|
||||||
|
*/
|
||||||
|
exposes?: PropertyMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmitMeta {
|
export interface EmitMeta {
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ function renderComponentPart(part: ComponentPartMeta): string[] {
|
|||||||
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
|
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
|
||||||
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
|
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (part.exposes && part.exposes.length > 0) {
|
||||||
|
const rows = part.exposes.map(x => [cell(x.name), cell(`\`${x.type}\``), cell(x.description)]);
|
||||||
|
out.push('#### Exposes (template ref)', '', table(['Name', 'Type', 'Description'], rows), '');
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<!-- title: Building flow graphs -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Prose + snippets only — static content, prerenders cleanly.
|
||||||
|
|
||||||
|
const minimal = `<script setup lang="ts">
|
||||||
|
import { FlowBackground, FlowControls, FlowRoot } from '@robonen/primitives';
|
||||||
|
import type { FlowEdge, FlowNode } from '@robonen/primitives';
|
||||||
|
|
||||||
|
const nodes: FlowNode[] = [
|
||||||
|
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'Start' } },
|
||||||
|
{ id: 'b', position: { x: 260, y: 120 }, data: { label: 'Finish' } },
|
||||||
|
];
|
||||||
|
const edges: FlowEdge[] = [
|
||||||
|
{ id: 'a-b', source: 'a', target: 'b', label: 'then' },
|
||||||
|
];
|
||||||
|
<\/script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- The pane fills this element — give it a real height. -->
|
||||||
|
<div style="height: 480px">
|
||||||
|
<FlowRoot :default-nodes="nodes" :default-edges="edges" fit-view-on-mount>
|
||||||
|
<template #node-default="{ node }">
|
||||||
|
<div class="card">{{ node.data.label }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<FlowBackground />
|
||||||
|
<FlowControls />
|
||||||
|
</FlowRoot>
|
||||||
|
</div>
|
||||||
|
</template>`;
|
||||||
|
|
||||||
|
const customNode = `<!-- Register per-type renderers via nodeTypes (module-level map)… -->
|
||||||
|
<FlowRoot :node-types="{ scene: SceneNode }" … />
|
||||||
|
|
||||||
|
<!-- …or inline via a #node-<type> scoped slot: -->
|
||||||
|
<FlowRoot :default-nodes="nodes">
|
||||||
|
<template #node-scene="{ node, selected }">
|
||||||
|
<article :data-selected="selected" class="scene">
|
||||||
|
<h4>{{ node.data.title }}</h4>
|
||||||
|
|
||||||
|
<!-- One SOURCE handle per row: anchor it to the row, not the side's
|
||||||
|
midpoint — same-position handles of one type otherwise overlap. -->
|
||||||
|
<div v-for="option in node.data.options" :key="option.id" class="row">
|
||||||
|
{{ option.label }}
|
||||||
|
<FlowHandle
|
||||||
|
:id="'opt:' + option.id"
|
||||||
|
type="source"
|
||||||
|
position="right"
|
||||||
|
class="row-port"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</FlowRoot>`;
|
||||||
|
|
||||||
|
const nodrag = `<!-- Form controls inside a node already win over dragging:
|
||||||
|
input, textarea, select, button, [contenteditable], [data-handleid]
|
||||||
|
start no drag. Everything else opts out with the .nodrag class: -->
|
||||||
|
<template #node-scene="{ node }">
|
||||||
|
<div class="scene">
|
||||||
|
<button @click="open(node.id)">Edit</button> <!-- just works -->
|
||||||
|
<div class="nodrag">
|
||||||
|
<MyColorWheel /> <!-- opted out -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>`;
|
||||||
|
|
||||||
|
const events = `<FlowRoot
|
||||||
|
:default-nodes="nodes"
|
||||||
|
@node-click="(id) => select(id)"
|
||||||
|
@node-double-click="(id) => openEditor(id)"
|
||||||
|
@node-drag-stop="(ids) => persistPositions(ids)"
|
||||||
|
@pane-click="clearInspector()"
|
||||||
|
@edge-click="(id) => selectEdge(id)"
|
||||||
|
/>`;
|
||||||
|
|
||||||
|
const instance = `<script setup lang="ts">
|
||||||
|
import { useTemplateRef } from 'vue';
|
||||||
|
import { FlowRoot } from '@robonen/primitives';
|
||||||
|
|
||||||
|
const flow = useTemplateRef('flow');
|
||||||
|
|
||||||
|
function frameSelection(ids: string[]) {
|
||||||
|
flow.value?.fitView({ padding: 0.2, nodes: ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAtCursor(event: MouseEvent) {
|
||||||
|
const position = flow.value!.screenToFlowPosition({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
// …push a node at \`position\`
|
||||||
|
}
|
||||||
|
<\/script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlowRoot ref="flow" :default-nodes="nodes" fit-view-on-mount />
|
||||||
|
</template>`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>Building flow graphs</h1>
|
||||||
|
<p>
|
||||||
|
<code>Flow</code> is a headless node-and-edge canvas: panning, zooming,
|
||||||
|
dragging, connecting, selection and virtualization are handled for you;
|
||||||
|
every pixel of a node is yours. This guide covers the contracts that are
|
||||||
|
easy to miss: sizing, custom nodes, drag opt-out, events and the
|
||||||
|
imperative API.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>A minimal graph</h2>
|
||||||
|
<p>
|
||||||
|
The pane fills its nearest sized ancestor — the graph lives in
|
||||||
|
absolutely-positioned layers, so the <em>host</em> element must have a
|
||||||
|
real height. <code>fit-view-on-mount</code> frames the graph once nodes
|
||||||
|
are measured; it is skipped when you control the viewport yourself
|
||||||
|
(<code>v-model:viewport</code> / <code>defaultViewport</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="minimal" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Custom nodes</h2>
|
||||||
|
<p>
|
||||||
|
Nodes render through a component map (<code>nodeTypes</code>, keyed by
|
||||||
|
<code>node.type</code>) or a <code>#node-<type></code> scoped
|
||||||
|
slot. The slot receives the internal node (<code>node.data</code> is
|
||||||
|
yours) and its <code>selected</code> state. Place
|
||||||
|
<code>FlowHandle</code>s anywhere inside — give repeated same-side
|
||||||
|
handles their own anchors, since handles of one type default to the
|
||||||
|
side's midpoint and would overlap.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="customNode" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Interactive content and <code>.nodrag</code></h2>
|
||||||
|
<p>
|
||||||
|
The drag layer owns <code>pointerdown</code> on the node. Native form
|
||||||
|
controls (<code>input</code>, <code>textarea</code>, <code>select</code>,
|
||||||
|
<code>button</code>), <code>[contenteditable]</code> elements and
|
||||||
|
handles are excluded automatically; any other interactive element opts
|
||||||
|
out of dragging with the <code>.nodrag</code> class.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="nodrag" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Click, double-click, drag</h2>
|
||||||
|
<p>
|
||||||
|
The drag layer distinguishes a settled click from a drag, so
|
||||||
|
<code>@node-click</code> never fires after a real move, and
|
||||||
|
<code>@node-double-click</code> pairs two settled clicks — double-click
|
||||||
|
on a node does <em>not</em> zoom the canvas. Positions are persisted
|
||||||
|
from <code>@node-drag-stop</code>, which reports every node that moved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="events" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The instance API</h2>
|
||||||
|
<p>
|
||||||
|
<code>FlowRoot</code> exposes its whole imperative surface through the
|
||||||
|
template ref — <code>fitView</code>, zooming, viewport get/set,
|
||||||
|
coordinate conversion (<code>screenToFlowPosition</code> /
|
||||||
|
<code>flowToScreenPosition</code>), node/edge lookups and selection
|
||||||
|
control. The full list is on the <code>Flow</code> component page under
|
||||||
|
<em>Exposes</em>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="instance" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Edge labels</h2>
|
||||||
|
<p>
|
||||||
|
An edge with a <code>label</code> renders it at the path midpoint as
|
||||||
|
<code>[data-flow-edge-label]</code>, haloed with
|
||||||
|
<code>--flow-edge-label-halo</code> (defaults to white) so it stays
|
||||||
|
readable over the wire. For richer labels, take over the edge with
|
||||||
|
<code>edgeTypes</code> or an <code>#edge-<type></code> slot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -86,16 +86,27 @@ export interface FlowRootProps extends PrimitiveProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowRootEmits {
|
export interface FlowRootEmits {
|
||||||
|
/** Granular node mutations (position, selection, removal) — apply them to your controlled state. */
|
||||||
nodesChange: [changes: NodeChange[]];
|
nodesChange: [changes: NodeChange[]];
|
||||||
|
/** Granular edge mutations (selection, removal). */
|
||||||
edgesChange: [changes: EdgeChange[]];
|
edgesChange: [changes: EdgeChange[]];
|
||||||
|
/** A connection gesture completed between two handles. */
|
||||||
connect: [connection: Connection];
|
connect: [connection: Connection];
|
||||||
|
/** A connection gesture started from a handle. */
|
||||||
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
|
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
|
||||||
|
/** The connection gesture ended, successfully or not. */
|
||||||
connectEnd: [];
|
connectEnd: [];
|
||||||
|
/** A node drag finished; ids of every node that moved. */
|
||||||
nodeDragStop: [ids: string[]];
|
nodeDragStop: [ids: string[]];
|
||||||
|
/** The set of selected nodes/edges changed. */
|
||||||
selectionChange: [selection: { nodes: string[]; edges: string[] }];
|
selectionChange: [selection: { nodes: string[]; edges: string[] }];
|
||||||
|
/** A click landed on the empty pane — not on a node or an edge. */
|
||||||
paneClick: [event: PointerEvent];
|
paneClick: [event: PointerEvent];
|
||||||
|
/** A settled click on a node (a drag that never started moving). */
|
||||||
nodeClick: [id: string, event: PointerEvent];
|
nodeClick: [id: string, event: PointerEvent];
|
||||||
|
/** Two settled clicks on the same node within the double-click interval. */
|
||||||
nodeDoubleClick: [id: string, event: PointerEvent];
|
nodeDoubleClick: [id: string, event: PointerEvent];
|
||||||
|
/** A click on an edge path. */
|
||||||
edgeClick: [id: string, event: PointerEvent];
|
edgeClick: [id: string, event: PointerEvent];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -145,6 +156,7 @@ const flowId = useId(undefined, 'flow').value;
|
|||||||
|
|
||||||
// ── models (controlled + uncontrolled) ────────────────────────────────────
|
// ── models (controlled + uncontrolled) ────────────────────────────────────
|
||||||
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
|
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
|
||||||
|
/** Current nodes (controlled `v-model:nodes` or internal state). */
|
||||||
const nodes = defineModel<FlowNode[]>('nodes', {
|
const nodes = defineModel<FlowNode[]>('nodes', {
|
||||||
get: external => external ?? localNodes.value,
|
get: external => external ?? localNodes.value,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -154,6 +166,7 @@ const nodes = defineModel<FlowNode[]>('nodes', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
|
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
|
||||||
|
/** Current edges (controlled `v-model:edges` or internal state). */
|
||||||
const edges = defineModel<FlowEdge[]>('edges', {
|
const edges = defineModel<FlowEdge[]>('edges', {
|
||||||
get: external => external ?? localEdges.value,
|
get: external => external ?? localEdges.value,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -163,6 +176,7 @@ const edges = defineModel<FlowEdge[]>('edges', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
|
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
|
||||||
|
/** Current viewport (controlled `v-model:viewport` or internal state). */
|
||||||
const viewport = defineModel<Viewport>('viewport', {
|
const viewport = defineModel<Viewport>('viewport', {
|
||||||
get: external => external ?? localViewport.value,
|
get: external => external ?? localViewport.value,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -178,6 +192,7 @@ const viewport = defineModel<Viewport>('viewport', {
|
|||||||
// would never visually update). ────────────────────────────────────────────
|
// would never visually update). ────────────────────────────────────────────
|
||||||
const nodeLookup = shallowRef(new Map<string, InternalNode>());
|
const nodeLookup = shallowRef(new Map<string, InternalNode>());
|
||||||
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
|
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
|
||||||
|
/** Selected node/edge id sets. */
|
||||||
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
|
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
|
||||||
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
|
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
|
||||||
const isDragging = shallowRef(false);
|
const isDragging = shallowRef(false);
|
||||||
@@ -357,6 +372,7 @@ function emitSelection(): void {
|
|||||||
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
|
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Select a node — replacing the selection, or adding to it. */
|
||||||
function selectNode(id: string, additive = false): void {
|
function selectNode(id: string, additive = false): void {
|
||||||
if (!elementsSelectable) return;
|
if (!elementsSelectable) return;
|
||||||
const sel = selection.value;
|
const sel = selection.value;
|
||||||
@@ -368,6 +384,7 @@ function selectNode(id: string, additive = false): void {
|
|||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Select an edge — replacing the selection, or adding to it. */
|
||||||
function selectEdge(id: string, additive = false): void {
|
function selectEdge(id: string, additive = false): void {
|
||||||
if (!elementsSelectable) return;
|
if (!elementsSelectable) return;
|
||||||
const sel = selection.value;
|
const sel = selection.value;
|
||||||
@@ -379,17 +396,20 @@ function selectEdge(id: string, additive = false): void {
|
|||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace the selection with exactly these nodes and edges. */
|
||||||
function setSelection(nodeIds: string[], edgeIds: string[]): void {
|
function setSelection(nodeIds: string[], edgeIds: string[]): void {
|
||||||
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
|
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
|
||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deselect everything. */
|
||||||
function clearSelection(): void {
|
function clearSelection(): void {
|
||||||
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
|
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
|
||||||
selection.value = { nodes: new Set(), edges: new Set() };
|
selection.value = { nodes: new Set(), edges: new Set() };
|
||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove every selected node (with its edges) and selected edge. */
|
||||||
function removeSelected(): void {
|
function removeSelected(): void {
|
||||||
const sel = selection.value;
|
const sel = selection.value;
|
||||||
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
|
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user