3 Commits

Author SHA1 Message Date
robonen edcccf16d8 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
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>
2026-08-11 03:37:23 +07:00
robonen d2838ba8ee fix(primitives): flow renders visibly by default and its declared events fire
- pane fills its parent instead of collapsing to the 0px strip every
  consumer debugged as a data bug; background/viewport/panel get a
  default stacking triple (0/1/2) so chrome no longer paints over nodes
- nodeClick/edgeClick/paneClick were declared in FlowRootEmits but never
  emitted — wired for real; nodeDoubleClick synthesized in the drag
  layer (it already tells clicks from drags), and dblclick-zoom ignores
  [data-flow-node] so opening a node no longer also zooms the canvas
- FlowEdge.label was typed but never rendered — the default edge now
  draws a haloed midpoint label, and label joins the v-memo keys so
  edits are not frozen by the memo
- fitViewOnMount prop fits once nodes AND the pane are measured (either
  can finish first), skipped when the viewport is controlled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:22:28 +07:00
robonen cc93715c03 fix(writekit): attr coercion stops erasing data, validate runs, undo coalesces
Three fixes driven by building a real consumer (cyrille studio) on 0.0.1, each
pinned by tests:

- `coerceAttrs` treated the spec as a whitelist: any attribute a block
  definition did not declare was silently deleted by the first
  `normalizeDocument` pass — and since normalization runs on load and consumers
  autosave, the erasure wrote itself back to storage. Coercion now fills
  defaults and keeps unknown keys verbatim. Parse rules build attrs explicitly,
  so pasted markup cannot smuggle keys through this path; the CRDT never calls
  coercion, so replica semantics are unchanged.

- `AttrSpec.validate` was consulted only by `validateDocument`, which nothing
  in the library calls — it looked like enforcement and was inert. A provided
  value failing `validate` now falls back to the declared default,
  deterministically (CRDT-safe given one spec) and loudly in dev.

- Undo recorded one entry per transaction — one keystroke per Ctrl+Z, and 200
  keystrokes evicted the entire earlier history. Plain typing in one block now
  coalesces within a 500ms window by concatenation, which preserves the replay
  invariant (`inverted` stays in application order, replayed reversed), counts
  as ONE entry against maxSize, and never merges across blocks, structural
  changes, or a foreign transaction (remote setDoc, undo/redo, selection-only
  moves interrupt the chain). `coalesceMs: 0` opts out.

Also: `component` in a block definition may now be a lazy loader
(`() => import('./Card.vue')`) — a registry imported for its schema (codecs,
tests, server-side normalizers) then carries no view graph; the view wraps the
loader in a cached async component on first render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 03:04:12 +07:00
24 changed files with 1100 additions and 29 deletions
+6 -1
View File
@@ -89,7 +89,12 @@ const roleColor: Record<string, string> = {
<DocsEmitsTable :emits="part.emits" />
</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.
</p>
</div>
+5
View File
@@ -12,6 +12,7 @@ defineProps<{
<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">Payload</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
@@ -22,6 +23,10 @@ defineProps<{
<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>
</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>
</tbody>
</table>
+34
View File
@@ -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>
+152 -6
View File
@@ -12,7 +12,7 @@
import { basename, dirname, relative, resolve } from 'node:path';
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 {
CategoryMeta,
@@ -858,6 +858,144 @@ function extractScriptBlock(sfc: string, setup: boolean): string {
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. */
function extractEmits(setupScript: string): EmitMeta[] {
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
@@ -907,7 +1045,7 @@ function extractModels(setupScript: string): { props: PropertyMeta[]; emits: Emi
defaultValue: null,
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 };
@@ -968,7 +1106,7 @@ function roleFromName(componentName: string, base: string): string {
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
* 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.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) return null;
@@ -1001,16 +1139,22 @@ function buildComponentAt(dir: string, slug: string, category: string, entryPoin
const role = roleFromName(name, base);
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/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
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 {
@@ -1030,6 +1174,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return [];
const sfcProject = buildSfcProject(pkgDir);
const components: ComponentMeta[] = [];
// Components live one level deep, in category folders: src/<category>/<component>/.
@@ -1048,13 +1193,14 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
compEntry.name,
label,
`./${catEntry.name}/${compEntry.name}`,
sfcProject,
);
if (c) components.push(c);
}
}
else {
// 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);
}
}
+5
View File
@@ -142,6 +142,11 @@ export interface ComponentPartMeta {
props: PropertyMeta[];
/** Emitted events parsed from `defineEmits` */
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 {
+5
View File
@@ -226,6 +226,11 @@ function renderComponentPart(part: ComponentPartMeta): string[] {
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
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;
}
+191
View File
@@ -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-&lt;type&gt;</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-&lt;type&gt;</code> slot.
</p>
</div>
</div>
</template>
@@ -54,7 +54,7 @@ const linePath = computed(() => {
<svg
data-flow-background=""
:data-variant="variant"
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', color }"
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, color }"
>
<pattern
:id="patternId"
+17 -1
View File
@@ -134,13 +134,14 @@ function onPointerdown(event: PointerEvent): void {
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
event.stopPropagation();
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
ctx.emitEdgeClick(id, event);
}
</script>
<template>
<g
v-if="endpoints"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, edge?.label, markerStartRef, markerEndRef]"
data-flow-edge=""
:data-id="id"
:data-type="resolvedType"
@@ -176,6 +177,21 @@ function onPointerdown(event: PointerEvent): void {
:style="interactionPathStyle"
@pointerdown="onPointerdown"
/>
<!-- The halo (paint-order + stroke) keeps the text legible over the
path and the background without the consumer styling anything. -->
<text
v-if="edge?.label"
data-flow-edge-label=""
:x="path[1]"
:y="path[2]"
text-anchor="middle"
dominant-baseline="middle"
fill="currentColor"
stroke="var(--flow-edge-label-halo, white)"
stroke-width="3"
paint-order="stroke"
:style="{ pointerEvents: 'none', fontSize: '12px' }"
>{{ edge.label }}</text>
</template>
</g>
</template>
+13 -2
View File
@@ -65,8 +65,10 @@ useKeyboard(currentElement, ctx, useViewportApi(ctx));
useEventListener(currentElement, 'click', (event: MouseEvent) => {
const target = event.target as Element | null;
if (target && !target.closest('[data-flow-node],[data-flow-edge]'))
if (target && !target.closest('[data-flow-node],[data-flow-edge]')) {
ctx.clearSelection();
ctx.emitPaneClick(event as PointerEvent);
}
});
</script>
@@ -79,7 +81,16 @@ useEventListener(currentElement, 'click', (event: MouseEvent) => {
:data-interactive="ctx.interactive.value ? '' : undefined"
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
:style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }"
:style="{
position: 'relative',
overflow: 'hidden',
touchAction: 'none',
// Everything inside is absolutely positioned, so content-sizing always
// collapsed to 0×N and the graph rendered into an invisible strip.
// Vue merges a consumer's style attr over this, so it stays overridable.
width: '100%',
height: '100%',
}"
>
<slot />
+3 -1
View File
@@ -28,7 +28,9 @@ const { forwardRef } = useForwardExpose();
const style = computed<CSSProperties>(() => {
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' };
// Above the viewport's explicit layer (zIndex 1): a positioned sibling
// with z-index auto would otherwise paint underneath the graph.
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all', zIndex: 2 };
s[v] = '0';
if (h === 'center') {
s.left = '50%';
+76 -1
View File
@@ -73,26 +73,46 @@ export interface FlowRootProps extends PrimitiveProps {
isValidConnection?: IsValidConnection;
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
onlyRenderVisibleElements?: boolean;
/**
* Frame the whole graph once after the initial nodes are measured. Skipped
* when an explicit `viewport` / `defaultViewport` is provided — a restored
* viewport must not be stomped by a fit. With virtualization the fit uses
* whatever is measured plus declared node sizes; fully unmeasured nodes are
* framed by position alone. @default false
*/
fitViewOnMount?: boolean | FitViewParams;
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
virtualizationBuffer?: number;
}
export interface FlowRootEmits {
/** Granular node mutations (position, selection, removal) — apply them to your controlled state. */
nodesChange: [changes: NodeChange[]];
/** Granular edge mutations (selection, removal). */
edgesChange: [changes: EdgeChange[]];
/** A connection gesture completed between two handles. */
connect: [connection: Connection];
/** A connection gesture started from a handle. */
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
/** The connection gesture ended, successfully or not. */
connectEnd: [];
/** A node drag finished; ids of every node that moved. */
nodeDragStop: [ids: string[]];
/** The set of selected nodes/edges changed. */
selectionChange: [selection: { nodes: string[]; edges: string[] }];
/** A click landed on the empty pane — not on a node or an edge. */
paneClick: [event: PointerEvent];
/** A settled click on a node (a drag that never started moving). */
nodeClick: [id: string, event: PointerEvent];
/** Two settled clicks on the same node within the double-click interval. */
nodeDoubleClick: [id: string, event: PointerEvent];
/** A click on an edge path. */
edgeClick: [id: string, event: PointerEvent];
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { computed, getCurrentInstance, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { useId } from '@robonen/vue';
import FlowPane from './FlowPane.vue';
import FlowViewport from './FlowViewport.vue';
@@ -124,6 +144,7 @@ const {
disableKeyboardA11y = false,
isValidConnection,
onlyRenderVisibleElements = false,
fitViewOnMount = false,
virtualizationBuffer = 200,
as = 'div',
} = defineProps<FlowRootProps>();
@@ -135,6 +156,7 @@ const flowId = useId(undefined, 'flow').value;
// ── models (controlled + uncontrolled) ────────────────────────────────────
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
/** Current nodes (controlled `v-model:nodes` or internal state). */
const nodes = defineModel<FlowNode[]>('nodes', {
get: external => external ?? localNodes.value,
set: (value) => {
@@ -144,6 +166,7 @@ const nodes = defineModel<FlowNode[]>('nodes', {
});
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
/** Current edges (controlled `v-model:edges` or internal state). */
const edges = defineModel<FlowEdge[]>('edges', {
get: external => external ?? localEdges.value,
set: (value) => {
@@ -153,6 +176,7 @@ const edges = defineModel<FlowEdge[]>('edges', {
});
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', {
get: external => external ?? localViewport.value,
set: (value) => {
@@ -168,6 +192,7 @@ const viewport = defineModel<Viewport>('viewport', {
// would never visually update). ────────────────────────────────────────────
const nodeLookup = shallowRef(new Map<string, InternalNode>());
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
/** Selected node/edge id sets. */
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
const isDragging = shallowRef(false);
@@ -330,6 +355,7 @@ function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNod
// pick up the fresh measurement / handle geometry.
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
triggerRef(nodeLookup);
maybeFitOnMount();
}
function updateNode(id: string, patch: Partial<FlowNode>): void {
@@ -346,6 +372,7 @@ function emitSelection(): void {
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 {
if (!elementsSelectable) return;
const sel = selection.value;
@@ -357,6 +384,7 @@ function selectNode(id: string, additive = false): void {
emitSelection();
}
/** Select an edge — replacing the selection, or adding to it. */
function selectEdge(id: string, additive = false): void {
if (!elementsSelectable) return;
const sel = selection.value;
@@ -368,17 +396,20 @@ function selectEdge(id: string, additive = false): void {
emitSelection();
}
/** Replace the selection with exactly these nodes and edges. */
function setSelection(nodeIds: string[], edgeIds: string[]): void {
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
emitSelection();
}
/** Deselect everything. */
function clearSelection(): void {
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
selection.value = { nodes: new Set(), edges: new Set() };
emitSelection();
}
/** Remove every selected node (with its edges) and selected edge. */
function removeSelected(): void {
const sel = selection.value;
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
@@ -515,12 +546,56 @@ const context: FlowContext = {
endConnection,
emitNodesChange: changes => emit('nodesChange', changes),
emitEdgesChange: changes => emit('edgesChange', changes),
emitNodeClick: (id, event) => emit('nodeClick', id, event),
emitNodeDoubleClick: (id, event) => emit('nodeDoubleClick', id, event),
emitEdgeClick: (id, event) => emit('edgeClick', id, event),
emitPaneClick: event => emit('paneClick', event),
};
provideFlowContext(context);
// Imperative API, also exposed so consumers can drive the flow via a template ref.
const api = useViewportApi(context);
// ── fitViewOnMount ────────────────────────────────────────────────────────
// A viewport the consumer controls (v-model:viewport) or seeds
// (defaultViewport) is restored state; a fit must never stomp it. Model
// getters fall back to a local default, so controlledness is read off the
// vnode, not the value.
const vnodeProps = getCurrentInstance()?.vnode.props ?? {};
let fitOnMountPending = fitViewOnMount !== false
&& defaultViewport === undefined
&& !('viewport' in vnodeProps)
&& !('onUpdate:viewport' in vnodeProps);
/**
* Armed until it fires once: waits for every RENDERED node to report a
* measurement — fitting to unmeasured nodes fits to nothing. Under
* virtualization only the rendered subset ever measures; the rest contribute
* their declared or positional bounds through `fitView` itself.
*/
function maybeFitOnMount(): void {
if (!fitOnMountPending) return;
// Nodes can finish measuring before the pane has a size (or the reverse);
// the shot must not burn against a 0×0 container, so both gates hold it and
// the pane-rect watcher below re-arms the attempt.
const rect = paneRect.value;
if (rect.width === 0 || rect.height === 0) return;
const map = nodeLookup.value;
if (map.size === 0) return;
for (const id of visibleNodeIds.value) {
const n = map.get(id);
if (n && n.measured.width === 0 && n.measured.height === 0) return;
}
fitOnMountPending = false;
api.fitView(typeof fitViewOnMount === 'object' ? fitViewOnMount : undefined);
}
watch(paneRect, maybeFitOnMount);
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
@@ -42,6 +42,9 @@ const transform = computed(() => {
left: '0',
width: '100%',
height: '100%',
// The slot (background, panels) renders after this element; explicit
// layers keep the graph above the background and below the chrome.
zIndex: 1,
transformOrigin: '0 0',
transform,
willChange: ctx.isInteracting.value ? 'transform' : undefined,
@@ -0,0 +1,191 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FlowEdge, FlowNode } from '../index';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { h, nextTick } from 'vue';
import { FlowBackground, FlowPanel, FlowRoot } from '../index';
/**
* Regressions found by building a real story-map consumer: the pane rendered
* into zero area, the background painted over the graph, edge labels never
* rendered, the declared click emits never fired, and dblclick on a node
* zoomed the canvas. Each test pins the fixed contract.
*/
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
const nodes: FlowNode[] = [
{ id: 'a', position: { x: 0, y: 0 } },
{ id: 'b', position: { x: 300, y: 200 } },
];
function pointer(el: Element, type: string, x = 10, y = 10) {
el.dispatchEvent(new PointerEvent(type, { button: 0, pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true }));
}
/** The pane sizes to its parent; give the test-utils wrapper a real box. */
function sizeWrapper(w: VueWrapper<any>, width = 600, height = 400) {
const el = w.element as HTMLElement;
el.style.width = `${width}px`;
el.style.height = `${height}px`;
}
const edges: FlowEdge[] = [
{ id: 'a-b', source: 'a', target: 'b', label: 'take me' },
];
function flow(props: Record<string, unknown> = {}, slots: Record<string, unknown> = {}) {
return track(mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: nodes, defaultEdges: edges, ...props },
slots: { 'node-default': () => h('div', { style: 'width:120px;height:40px' }, 'n'), ...slots },
}));
}
describe('pane sizing', () => {
it('fills its parent instead of collapsing to zero height', () => {
const w = flow();
sizeWrapper(w);
const pane = w.find('[data-flow-pane]').element as HTMLElement;
// All pane content is absolutely positioned; without an own height the
// whole graph rendered inside an invisible 0px strip.
expect(pane.clientHeight).toBe(400);
});
});
describe('stacking', () => {
it('layers background under the graph and panels above it', () => {
const w = flow({}, {
default: () => [h(FlowBackground), h(FlowPanel, { position: 'top-right' }, () => 'p')],
});
const viewport = (w.find('[data-flow-viewport]').element as HTMLElement).style.zIndex;
const background = (w.find('[data-flow-background]').element as HTMLElement).style.zIndex;
const panel = (w.find('[data-flow-panel]').element as HTMLElement).style.zIndex;
// The slot chrome renders AFTER the viewport in DOM order; without these
// layers the background dots painted over every node.
expect(Number(background)).toBeLessThan(Number(viewport));
expect(Number(panel)).toBeGreaterThan(Number(viewport));
});
});
describe('edge labels', () => {
it('renders the label the type always promised', async () => {
const w = flow();
await nextTick();
const label = w.find('[data-flow-edge-label]');
expect(label.exists()).toBe(true);
expect(label.text()).toBe('take me');
});
it('renders no label element when there is none', async () => {
const w = flow({ defaultEdges: [{ id: 'a-b', source: 'a', target: 'b' }] });
await nextTick();
expect(w.find('[data-flow-edge-label]').exists()).toBe(false);
});
});
describe('the click family', () => {
async function settle(w: VueWrapper<any>, selector: string, times = 1, gap = 50) {
const el = w.find(selector).element;
for (let index = 0; index < times; index++) {
pointer(el, 'pointerdown');
pointer(el, 'pointerup');
await nextTick();
if (gap)
await new Promise(resolve => setTimeout(resolve, gap));
}
}
it('emits nodeClick for a settled click', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]');
expect(w.emitted('nodeClick')?.[0]?.[0]).toBe('a');
});
it('pairs two settled clicks into nodeDoubleClick', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]', 2, 40);
expect(w.emitted('nodeDoubleClick')?.[0]?.[0]).toBe('a');
});
it('emits paneClick only for background clicks', async () => {
const w = flow();
await nextTick();
await w.find('[data-flow-pane]').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
await w.find('[data-flow-node][data-id="a"] div').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
});
it('emits edgeClick when the edge is picked', async () => {
const w = flow();
await nextTick();
pointer(w.findAll('[data-flow-edge] path')[1]!.element, 'pointerdown');
await nextTick();
expect(w.emitted('edgeClick')?.[0]?.[0]).toBe('a-b');
});
it('does not zoom on a node double click', async () => {
const w = flow();
await nextTick();
const before = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
await w.find('[data-flow-node][data-id="a"]').trigger('dblclick');
await nextTick();
// The gesture belongs to the node (nodeDoubleClick), not the camera.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform).toBe(before);
});
});
describe('fitViewOnMount', () => {
it('frames the graph once nodes are measured', async () => {
const w = flow({ fitViewOnMount: true });
sizeWrapper(w);
await vi.waitFor(() => {
const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
expect(t).not.toBe('translate(0px, 0px) scale(1)');
});
});
it('never stomps a consumer-controlled viewport', async () => {
const w = flow({
fitViewOnMount: true,
viewport: { x: 17, y: 23, zoom: 1.5 },
'onUpdate:viewport': () => {},
});
await new Promise(resolve => setTimeout(resolve, 120));
// A bound viewport is restored state; the fit must skip it entirely.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform)
.toBe('translate(17px, 23px) scale(1.5)');
});
});
@@ -35,6 +35,9 @@ export interface NodeDragOptions {
/** Elements inside a node that must not initiate a drag. */
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
/** Two settled clicks within this window read as a double click. */
const DOUBLE_CLICK_MS = 350;
/**
* Pointer-capture node drag. Moves the node (and every co-selected node) by the
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
@@ -57,6 +60,7 @@ export function useNodeDrag(
let startX = 0;
let startY = 0;
let started = false;
let lastClickAt = 0;
let lastX = 0;
let lastY = 0;
let rafId: number | null = null;
@@ -150,6 +154,26 @@ export function useNodeDrag(
if (started) {
flush();
ctx.commitNodeDrag();
lastClickAt = 0;
}
else if (snapshot.size > 0) {
// The pointer never crossed the drag threshold: this is a click. The
// pane cannot see it (propagation stopped on pointerdown), so the node
// is the only place that can report it — and pair two settled clicks
// into a double click.
const id = toValue(nodeId);
ctx.emitNodeClick(id, event);
const now = event.timeStamp;
if (now - lastClickAt <= DOUBLE_CLICK_MS) {
ctx.emitNodeDoubleClick(id, event);
lastClickAt = 0;
}
else {
lastClickAt = now;
}
}
pointerId = -1;
started = false;
@@ -158,7 +158,9 @@ export function usePanZoom(
// ── double-click zoom ──────────────────────────────────────────────────────
useEventListener(target, 'dblclick', (event: MouseEvent) => {
if (!zoomOnDoubleClick || !ctx.interactive.value) return;
if (event.target instanceof Element && event.target.closest('.nopan')) return;
// A double click on a node belongs to the node (nodeDoubleClick), not
// to the zoom gesture.
if (event.target instanceof Element && event.target.closest('.nopan, [data-flow-node]')) return;
const vp = current();
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
if (newZoom === vp.zoom) return;
@@ -121,6 +121,10 @@ export interface FlowContext {
// ── change emission ──────────────────────────────────────────────────────
emitNodesChange: (changes: NodeChange[]) => void;
emitEdgesChange: (changes: EdgeChange[]) => void;
emitNodeClick: (id: string, event: PointerEvent) => void;
emitNodeDoubleClick: (id: string, event: PointerEvent) => void;
emitEdgeClick: (id: string, event: PointerEvent) => void;
emitPaneClick: (event: PointerEvent) => void;
}
const flow = useContextFactory<FlowContext>('FlowContext');
+9 -1
View File
@@ -4,6 +4,9 @@ import type { NodeSpec } from '../schema';
import type { CommandFactory } from '../state/command';
import type { InputRuleSpec } from './input-rule';
/** A lazy block component: resolved by the view on first render. */
export type BlockComponentLoader = () => Promise<Component | { default: Component }>;
/** Props passed to an atom/void block's Vue `component`. */
export interface BlockComponentProps {
/** The block's model node (read its `attrs`). */
@@ -36,11 +39,16 @@ export interface BlockBehavior {
* A block definition: schema contribution + behavior + an opaque Vue component.
* Non-view layers treat `component` as an opaque value; only the view resolves
* it. The type is `Component` purely for authoring ergonomics (type-only import).
*
* `component` may be a lazy loader (`() => import('./Card.vue')`): a registry
* imported for its SCHEMA — a codec, a test, a server-side normalizer — then
* carries no view graph at all, and the view resolves the loader on first
* render exactly like any async component.
*/
export interface BlockDefinition {
readonly type: string;
readonly spec: NodeSpec;
readonly component?: Component;
readonly component?: Component | BlockComponentLoader;
readonly meta?: BlockMeta;
readonly behavior?: BlockBehavior;
readonly commands?: Record<string, CommandFactory>;
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { normalizeDocument } from '../normalize';
import { createSchema } from '../schema';
const schema = createSchema({
nodes: new Map([
['paragraph', {
content: { kind: 'text' as const },
attrs: {
condition: { default: null },
level: { default: 1, validate: (v: unknown) => typeof v === 'number' && v >= 1 && v <= 6 },
},
}],
['bare', { content: { kind: 'atom' as const } }],
]),
marks: new Map(),
});
describe('attr coercion', () => {
it('keeps unknown attributes instead of erasing them', () => {
// Coercion is not a whitelist: a document must round-trip through an
// editor whose schema does not know every field — dropping them silently
// erased consumer data, and the loss was autosaved before anyone saw it.
const attrs = schema.coerceAttrs('paragraph', {
condition: { op: 'flag', key: 'met' },
futureField: 'still here',
});
expect(attrs.futureField).toBe('still here');
expect(attrs.condition).toEqual({ op: 'flag', key: 'met' });
expect(attrs.level).toBe(1);
});
it('keeps attrs even when the spec declares none', () => {
expect(schema.coerceAttrs('bare', { anything: 1 })).toEqual({ anything: 1 });
});
it('runs validate and falls back to the default on a rejected value', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// `validate` looked like enforcement and never ran; an out-of-range level
// normalized cleanly and rendered <h99>.
const attrs = schema.coerceAttrs('paragraph', { level: 99 });
expect(attrs.level).toBe(1);
expect(warn).toHaveBeenCalledOnce();
warn.mockRestore();
});
it('accepts a value validate approves', () => {
expect(schema.coerceAttrs('paragraph', { level: 3 }).level).toBe(3);
});
it('is idempotent — a second pass changes nothing', () => {
const once = schema.coerceAttrs('paragraph', { level: 2, custom: [1, 2] });
const twice = schema.coerceAttrs('paragraph', once);
expect(twice).toEqual(once);
});
it('carries unknown attrs through normalizeDocument', () => {
const doc = {
content: [{
id: 'b1',
type: 'paragraph',
attrs: { condition: { op: 'flag', key: 'met' }, futureField: true },
content: [{ text: 'hi', marks: [] }],
}],
};
const normalized = normalizeDocument(doc as never, schema);
expect(normalized.content[0]!.attrs.futureField).toBe(true);
expect(normalized.content[0]!.attrs.condition).toEqual({ op: 'flag', key: 'met' });
});
});
+41 -7
View File
@@ -14,27 +14,61 @@ export interface Schema {
markSpec: (type: string) => MarkSpec | undefined;
/** Default attrs for a block type (all defaults applied). */
defaultAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a block type. */
/** Fill defaults, run `validate`, keep unknown keys for a block type. */
coerceAttrs: (type: string, attrs?: Attrs) => Attrs;
/** Default attrs for a mark type. */
defaultMarkAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a mark type. */
/** Fill defaults, run `validate`, keep unknown keys for a mark type. */
coerceMarkAttrs: (type: string, attrs?: Attrs) => Attrs;
}
/**
* Coercion fills defaults and enforces `validate`; it is NOT a whitelist.
*
* Unknown keys pass through verbatim: a document round-tripping through the
* editor must never lose fields this schema version does not know about —
* dropping them silently erased consumer data (a `condition` attribute the
* spec forgot to declare disappeared on the first normalization pass and the
* loss was autosaved). Parse rules build attrs explicitly, so pasted markup
* cannot smuggle arbitrary keys through this path.
*
* A provided value failing its `validate` falls back to the declared default:
* deterministic for CRDT replicas (given one spec), loud in dev, and never a
* silently-kept invalid value.
*/
function coerceWithSpec(spec: AttrsSpec | undefined, attrs?: Attrs): Attrs {
if (!spec)
return {};
if (!spec) {
return attrs ? { ...attrs } : {};
}
const result: Record<string, AttrValue> = {};
if (attrs) {
for (const key in attrs) {
if (attrs[key] !== undefined && !(key in spec))
result[key] = attrs[key]!;
}
}
for (const key in spec) {
const attr = spec[key]!;
const provided = attrs?.[key];
if (provided !== undefined)
if (provided !== undefined) {
if (attr.validate && !attr.validate(provided)) {
if (__DEV__)
console.warn(`[writekit] Attr "${key}" rejected by validate(); falling back to its default.`, provided);
if (attr.default !== undefined)
result[key] = attr.default;
}
else {
result[key] = provided;
else if (spec[key]!.default !== undefined)
result[key] = spec[key]!.default!;
}
}
else if (attr.default !== undefined) {
result[key] = attr.default;
}
}
return result;
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HistoryEntry } from '../history';
import type { Step } from '../step';
import { createHistory } from '../history';
const caret = { type: 'text', anchor: { blockId: 'b1', offset: 0 }, focus: { blockId: 'b1', offset: 0 } } as never;
function typing(blockId: string, text: string): HistoryEntry {
return {
steps: [{ type: 'insertInline', blockId, offset: 0, content: [{ text, marks: [] }] } as Step],
inverted: [{ type: 'deleteText', blockId, from: 0, to: text.length } as Step],
selectionBefore: caret,
selectionAfter: caret,
};
}
function structural(blockId: string): HistoryEntry {
return {
steps: [{ type: 'removeBlock', blockId } as Step],
inverted: [{ type: 'insertBlock', node: { id: blockId, type: 'paragraph', attrs: {}, content: [] }, index: 0 } as never],
selectionBefore: caret,
selectionAfter: caret,
};
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('history coalescing', () => {
it('merges a typing burst in one block into one undo press', () => {
const history = createHistory();
for (const ch of ['h', 'e', 'l', 'l', 'o']) {
history.record(typing('b1', ch));
vi.advanceTimersByTime(100);
}
const entry = history.undo()!;
expect(entry.steps).toHaveLength(5);
expect(history.canUndo()).toBe(false);
});
it('keeps the replay order: later keystrokes undo first', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
const entry = history.undo()!;
// `inverted` stays in application order; undo replays it reversed, so the
// inverse of "b" must sit AFTER the inverse of "a".
expect(entry.inverted.map(step => (step as { to: number }).to)).toEqual([1, 1]);
expect(entry.steps.map(step => (step as { content: Array<{ text: string }> }).content[0]!.text)).toEqual(['a', 'b']);
});
it('starts a new group after the time window', () => {
const history = createHistory({ coalesceMs: 500 });
history.record(typing('b1', 'a'));
vi.advanceTimersByTime(600);
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges across blocks', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b2', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges structural changes', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(structural('b1'));
history.record(typing('b1', 'b'));
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
});
it('breaks the chain on interrupt — a foreign transaction is a boundary', () => {
// A remote setDoc or an undo between keystrokes must not be spliced into
// one undo press with them.
const history = createHistory();
history.record(typing('b1', 'a'));
history.interrupt();
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('counts groups, not keystrokes, against maxSize', () => {
const history = createHistory({ maxSize: 2 });
// Two bursts of three keystrokes: two groups — both must survive.
for (const ch of ['a', 'b', 'c'])
history.record(typing('b1', ch));
vi.advanceTimersByTime(1000);
for (const ch of ['d', 'e', 'f'])
history.record(typing('b1', ch));
expect(history.undo()!.steps).toHaveLength(3);
expect(history.undo()!.steps).toHaveLength(3);
expect(history.canUndo()).toBe(false);
});
it('can be disabled outright', () => {
const history = createHistory({ coalesceMs: 0 });
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
});
+73
View File
@@ -15,6 +15,14 @@ export interface HistoryEntry {
export interface HistoryOptions {
/** Maximum number of undo entries to retain (default 200). */
readonly maxSize?: number;
/**
* Coalesce a new entry into the previous one when both are plain typing in
* the same block and land within this window (ms). One keystroke per
* transaction otherwise makes Ctrl+Z a character-by-character crawl, and a
* short paragraph evicts the whole earlier history through `maxSize`.
* `0` disables coalescing. @default 500
*/
readonly coalesceMs?: number;
}
/**
@@ -26,6 +34,13 @@ export interface HistoryOptions {
export interface History {
/** Record a new edit, clearing the redo stack. */
record: (entry: HistoryEntry) => void;
/**
* Break the coalescing chain: the next recorded entry starts its own group.
* Called for anything that lands between recordings (a remote change, an
* undo/redo, a selection jump) — merging across such a boundary would splice
* foreign state into one undo press.
*/
interrupt: () => void;
/** Pop the latest undo entry (and push it onto the redo stack). */
undo: () => HistoryEntry | undefined;
/** Pop the latest redo entry (and push it back onto the undo stack). */
@@ -35,18 +50,75 @@ export interface History {
clear: () => void;
}
/** Plain typing: text-only steps confined to a single block. */
function typingBlockOf(steps: readonly Step[]): string | null {
let block: string | null = null;
for (const step of steps) {
if (step.type !== 'insertInline' && step.type !== 'deleteText' && step.type !== 'replaceInline')
return null;
if (block === null)
block = step.blockId;
else if (block !== step.blockId)
return null;
}
return block;
}
export function createHistory(options: HistoryOptions = {}): History {
const maxSize = options.maxSize ?? 200;
const coalesceMs = options.coalesceMs ?? 500;
const undoStack: HistoryEntry[] = [];
const redoStack: HistoryEntry[] = [];
let lastRecordAt = 0;
let lastTypingBlock: string | null = null;
/**
* Concatenation preserves the replay invariant: `inverted` is stored in
* application order and replayed reversed, so a merged entry undoes the
* later keystrokes first — exactly as separate entries would, in one press.
*/
function coalesce(top: HistoryEntry, entry: HistoryEntry): HistoryEntry {
return {
steps: [...top.steps, ...entry.steps],
inverted: [...top.inverted, ...entry.inverted],
selectionBefore: top.selectionBefore,
selectionAfter: entry.selectionAfter,
};
}
return {
record(entry) {
const now = Date.now();
const block = typingBlockOf(entry.steps);
const top = undoStack[undoStack.length - 1];
const mergeable
= coalesceMs > 0
&& top !== undefined
&& block !== null
&& block === lastTypingBlock
&& now - lastRecordAt <= coalesceMs;
if (mergeable) {
undoStack[undoStack.length - 1] = coalesce(top, entry);
}
else {
undoStack.push(entry);
if (undoStack.length > maxSize)
undoStack.shift();
}
lastRecordAt = now;
lastTypingBlock = block;
redoStack.length = 0;
},
interrupt() {
lastTypingBlock = null;
},
undo() {
const entry = undoStack.pop();
if (entry)
@@ -64,6 +136,7 @@ export function createHistory(options: HistoryOptions = {}): History {
clear() {
undoStack.length = 0;
redoStack.length = 0;
lastTypingBlock = null;
},
};
}
+5
View File
@@ -63,6 +63,11 @@ export function createWritekit(options: CreateWritekitOptions): Writekit {
selectionAfter: next.selection,
});
}
else {
// Anything that lands between recordings — a remote setDoc, undo/redo, a
// selection-only move — is a boundary the coalescer must not merge over.
history.interrupt();
}
bus.emit('transaction', tr, next, prev);
if (next.doc !== prev.doc)
+27 -3
View File
@@ -3,8 +3,9 @@ import type { Attrs, Node } from '../model';
</script>
<script setup lang="ts">
import type { IntrinsicElementAttributes } from 'vue';
import { computed } from 'vue';
import type { Component, IntrinsicElementAttributes } from 'vue';
import type { BlockDefinition } from '../registry';
import { computed, defineAsyncComponent } from 'vue';
import { nodeSelection } from '../model';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
@@ -21,7 +22,30 @@ const ctx = useWritekitContext();
const def = computed(() => ctx.registry.getBlock(block.type));
const wrapperTag = computed<keyof IntrinsicElementAttributes>(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes);
const isText = computed(() => def.value?.spec.content.kind === 'text');
const atomComponent = computed(() => def.value?.component);
/**
* A function-shaped `component` is a lazy loader; wrap it once per definition
* so repeated renders reuse the same async component (and its resolved state)
* instead of re-importing per block instance.
*/
const asyncCache = new WeakMap<() => Promise<unknown>, Component>();
function resolveComponent(raw: BlockDefinition['component']): Component | undefined {
if (typeof raw !== 'function' || (raw as Component & { render?: unknown }).render || (raw as { setup?: unknown }).setup)
return raw as Component | undefined;
const loader = raw as () => Promise<Component | { default: Component }>;
let wrapped = asyncCache.get(loader);
if (!wrapped) {
wrapped = defineAsyncComponent(() =>
loader().then(m => ('default' in m ? m.default : m) as Component));
asyncCache.set(loader, wrapped);
}
return wrapped;
}
const atomComponent = computed(() => resolveComponent(def.value?.component));
const isSelected = computed(() => {
const sel = ctx.state.value.selection;
return sel.kind === 'node' && sel.ids.includes(block.id);