Compare commits
6 Commits
ea96d720f2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 551b3bd921 | |||
| 1d105b1f55 | |||
| 66d9faad22 | |||
| edcccf16d8 | |||
| d2838ba8ee | |||
| cc93715c03 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/primitives",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/primitives",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of UI primitives",
|
||||
"keywords": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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%';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/writekit",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
|
||||
"keywords": [
|
||||
|
||||
@@ -9,5 +9,5 @@ export const blockquote = defineBlock({
|
||||
parseDOM: [{ tag: 'blockquote' }],
|
||||
},
|
||||
inputRules: [{ match: /^>\s$/ }],
|
||||
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic' },
|
||||
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic', description: 'Set a passage apart from the narration.' },
|
||||
});
|
||||
|
||||
@@ -13,5 +13,5 @@ export const callout = defineBlock({
|
||||
getAttrs: (el: HTMLElement) => ({ variant: el.getAttribute('data-callout') ?? 'info' }),
|
||||
}],
|
||||
},
|
||||
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic' },
|
||||
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic', description: 'A highlighted note the eye cannot miss.' },
|
||||
});
|
||||
|
||||
@@ -12,5 +12,5 @@ export const codeBlock = defineBlock({
|
||||
toDOM: (node: Node) => ['pre', { 'data-language': String(node.attrs['language'] ?? 'plain') }, 0],
|
||||
parseDOM: [{ tag: 'pre' }],
|
||||
},
|
||||
meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic' },
|
||||
meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic', description: 'Verbatim monospaced text; Enter stays inside.' },
|
||||
});
|
||||
|
||||
@@ -10,5 +10,5 @@ export const divider = defineBlock({
|
||||
parseDOM: [{ tag: 'hr' }],
|
||||
},
|
||||
component: DividerBlock,
|
||||
meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media' },
|
||||
meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media', description: 'A horizontal rule between sections.' },
|
||||
});
|
||||
|
||||
@@ -14,5 +14,5 @@ export const heading = defineBlock({
|
||||
parseDOM: LEVELS.map(level => ({ tag: `h${level}`, attrs: { level } })),
|
||||
},
|
||||
inputRules: LEVELS.map(level => ({ match: new RegExp(`^#{${level}}\\s$`), attrs: { level } })),
|
||||
meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic' },
|
||||
meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic', description: 'A section title, levels 1–6.' },
|
||||
});
|
||||
|
||||
@@ -23,5 +23,5 @@ export const image = defineBlock({
|
||||
}],
|
||||
},
|
||||
component: ImageBlock,
|
||||
meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media' },
|
||||
meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media', description: 'An image with an optional caption.' },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ function indentOf(node: Node): number {
|
||||
* `checked` for to-dos). Markers/numbering and indentation are presentation
|
||||
* (CSS), so the model stays a simple flat block list that maps cleanly to a CRDT.
|
||||
*/
|
||||
function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[] }) {
|
||||
function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[]; description?: string }) {
|
||||
const todo = options.listType === 'todo';
|
||||
|
||||
const attrs: AttrsSpec = {
|
||||
@@ -43,10 +43,16 @@ function defineListBlock(options: { type: string; listType: ListType; title: str
|
||||
parseDOM: [{ tag: `[data-list='${options.listType}']` }],
|
||||
},
|
||||
inputRules,
|
||||
meta: { title: options.title, icon: 'list', keywords: options.keywords, group: 'lists' },
|
||||
meta: {
|
||||
title: options.title,
|
||||
icon: 'list',
|
||||
keywords: options.keywords,
|
||||
group: 'lists',
|
||||
...(options.description !== undefined && { description: options.description }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'] });
|
||||
export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'] });
|
||||
export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'] });
|
||||
export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'], description: 'Items marked with bullets; Tab indents.' });
|
||||
export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'], description: 'Items numbered in order; Tab indents.' });
|
||||
export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'], description: 'Checkable tasks; Enter adds the next one.' });
|
||||
|
||||
@@ -9,5 +9,5 @@ export const paragraph = defineBlock({
|
||||
parseDOM: [{ tag: 'p' }],
|
||||
},
|
||||
placeholder: 'Write something…',
|
||||
meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic' },
|
||||
meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic', description: 'Plain prose — the default block.' },
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
|
||||
import { caret, createDoc, createNode, nodeInline, nodeSelection, nodeText, textSelection } from '../../model';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { createWritekit, createWritekitState } from '../../state';
|
||||
import { joinBackward, splitBlock, toggleMark } from '..';
|
||||
import { exitAtom, joinBackward, splitBlock, toggleMark } from '..';
|
||||
|
||||
function para(id: string, text: string) {
|
||||
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
|
||||
@@ -44,6 +44,31 @@ describe('commands', () => {
|
||||
expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
|
||||
});
|
||||
|
||||
it('exitAtom starts a paragraph below a selected atom', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({
|
||||
registry,
|
||||
doc: createDoc([para('a', 'before'), createNode('divider', { id: 'd' })]),
|
||||
selection: nodeSelection(['d']),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(writekit.command(exitAtom)).toBe(true);
|
||||
|
||||
const types = writekit.state.doc.content.map(block => block.type);
|
||||
expect(types).toEqual(['paragraph', 'divider', 'paragraph']);
|
||||
|
||||
const sel = writekit.state.selection;
|
||||
expect(sel.kind).toBe('text');
|
||||
expect(sel.kind === 'text' && sel.focus.blockId).toBe(writekit.state.doc.content[2]!.id);
|
||||
});
|
||||
|
||||
it('exitAtom is a no-op for text selections', () => {
|
||||
const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
|
||||
expect(writekit.command(exitAtom)).toBe(false);
|
||||
});
|
||||
|
||||
it('undo restores the document after a split', () => {
|
||||
const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
|
||||
writekit.command(splitBlock);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Attrs, Node } from '../model';
|
||||
import {
|
||||
blockById,
|
||||
caret,
|
||||
createNode,
|
||||
inlineLength,
|
||||
isAcrossBlocks,
|
||||
isCollapsed,
|
||||
@@ -88,6 +89,36 @@ export const splitBlock: Command = (state, dispatch) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter with an atom selected: start a paragraph right below it.
|
||||
*
|
||||
* An atom (image, divider, an app's card) has no text position inside it, so
|
||||
* without this the only way OUT of a selected atom — and the only way to write
|
||||
* between two atoms, or after one that ends the document — was to abandon the
|
||||
* keyboard. Mirrors `createParagraphNear` in the ProseMirror tradition.
|
||||
*/
|
||||
export const exitAtom: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'node' || sel.ids.length === 0 || !state.registry.hasBlock('paragraph'))
|
||||
return false;
|
||||
|
||||
const lastId = sel.ids.at(-1)!;
|
||||
const index = state.doc.content.findIndex(block => block.id === lastId);
|
||||
|
||||
if (index === -1)
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
const paragraph = createNode('paragraph');
|
||||
dispatch(createTransaction(state)
|
||||
.insertBlock(paragraph, index + 1)
|
||||
.setSelection(caret(paragraph.id, 0)));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Insert a hard line break (Shift+Enter) inside the current block. */
|
||||
export const insertHardBreak: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
chainCommands,
|
||||
deleteSelection,
|
||||
exitAtom,
|
||||
indentListItem,
|
||||
insertHardBreak,
|
||||
joinBackward,
|
||||
@@ -36,7 +37,8 @@ export function defaultKeymap(writekit: Writekit): Keymap {
|
||||
'Mod-z': undo,
|
||||
'Mod-Shift-z': redo,
|
||||
'Mod-y': redo,
|
||||
Enter: splitBlock,
|
||||
// With an atom selected, Enter starts a paragraph below it; in text it splits.
|
||||
Enter: chainCommands(exitAtom, splitBlock),
|
||||
'Shift-Enter': insertHardBreak,
|
||||
Backspace: chainCommands(deleteSelection, joinBackward),
|
||||
Delete: chainCommands(deleteSelection, joinForward),
|
||||
|
||||
@@ -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`). */
|
||||
@@ -22,6 +25,8 @@ export interface BlockMeta {
|
||||
readonly icon?: string;
|
||||
readonly keywords?: readonly string[];
|
||||
readonly group?: string;
|
||||
/** One sentence for pickers (the slash menu shows it beside the list). */
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
/** Optional block-specific behaviors used by core commands. */
|
||||
@@ -36,11 +41,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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { PrimitiveProps } from './primitive';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { blockById, caret, inlineLength, isCollapsed, nodeInline } from '../model';
|
||||
import { applyInputRule, deleteSelection, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
|
||||
import { blockById, caret, createNode, inlineLength, isCollapsed, nodeInline } from '../model';
|
||||
import { applyInputRule, deleteSelection, exitAtom, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
|
||||
import { createTransaction } from '../state';
|
||||
import { Primitive } from './primitive';
|
||||
import { useWritekitContext } from './context';
|
||||
@@ -35,6 +35,20 @@ function onBeforeInput(event: InputEvent): void {
|
||||
if (!type.startsWith('insert') && !type.startsWith('delete'))
|
||||
return;
|
||||
|
||||
// With an atom selected the native range wraps the block element; letting the
|
||||
// browser edit through it would rewrite DOM the model never agreed to.
|
||||
const modelSel = ctx.writekit.state.selection;
|
||||
if (modelSel.kind === 'node') {
|
||||
event.preventDefault();
|
||||
|
||||
if (type.startsWith('delete'))
|
||||
ctx.writekit.command(deleteSelection);
|
||||
else if (type === 'insertParagraph')
|
||||
ctx.writekit.command(exitAtom);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = ctx.selection.read();
|
||||
if (!sel || sel.kind !== 'text')
|
||||
return;
|
||||
@@ -117,6 +131,41 @@ function onInput(event?: Event): void {
|
||||
ctx.writekit.command(applyInputRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on the root's own padding below the last block means "write here".
|
||||
* When the document ends in an atom there is no text position to click into at
|
||||
* all — without this the only way to continue writing was the keyboard path
|
||||
* (select the atom, press Enter). Ends-in-text just places the caret at the end.
|
||||
*/
|
||||
function onRootPointerDown(event: PointerEvent): void {
|
||||
if (!ctx.config.editable || event.target !== ctx.contentRoot.value)
|
||||
return;
|
||||
|
||||
const last = ctx.writekit.state.doc.content.at(-1);
|
||||
if (!last)
|
||||
return;
|
||||
|
||||
const lastEl = ctx.blockElements.get(last.id) ?? null;
|
||||
if (lastEl && event.clientY <= lastEl.getBoundingClientRect().bottom)
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (ctx.writekit.state.schema.nodeSpec(last.type)?.content.kind === 'text') {
|
||||
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
|
||||
.setSelection(caret(last.id, inlineLength(nodeInline(last)))));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.writekit.state.registry.hasBlock('paragraph'))
|
||||
return;
|
||||
|
||||
const paragraph = createNode('paragraph');
|
||||
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
|
||||
.insertBlock(paragraph, ctx.writekit.state.doc.content.length)
|
||||
.setSelection(caret(paragraph.id, 0)));
|
||||
}
|
||||
|
||||
function onCompositionStart(event: CompositionEvent): void {
|
||||
if (isInteractiveTarget(event.target))
|
||||
return;
|
||||
@@ -143,6 +192,7 @@ function onCompositionEnd(event: CompositionEvent): void {
|
||||
:spellcheck="ctx.config.spellcheck"
|
||||
@beforeinput="onBeforeInput"
|
||||
@input="onInput"
|
||||
@pointerdown="onRootPointerDown"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionend="onCompositionEnd"
|
||||
>
|
||||
|
||||
@@ -134,3 +134,97 @@ describe('WritekitRoot (single contenteditable)', () => {
|
||||
expect(hosts[0]!.textContent).toBe('foobar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writing after a trailing atom', () => {
|
||||
it('a click below the last block starts a paragraph when the doc ends in an atom', async () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({
|
||||
registry,
|
||||
doc: createDoc([para('a', 'text'), createNode('divider', { id: 'd' })]),
|
||||
}),
|
||||
});
|
||||
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
|
||||
await nextTick();
|
||||
|
||||
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
|
||||
root.style.paddingBottom = '120px';
|
||||
const rect = root.getBoundingClientRect();
|
||||
|
||||
root.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.left + 10,
|
||||
clientY: rect.bottom - 10,
|
||||
}));
|
||||
await nextTick();
|
||||
|
||||
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
|
||||
expect(writekit.state.selection.kind).toBe('text');
|
||||
});
|
||||
|
||||
it('a click below the last block just places the caret when it is text', async () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({ registry, doc: createDoc([para('a', 'text')]) }),
|
||||
});
|
||||
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
|
||||
await nextTick();
|
||||
|
||||
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
|
||||
root.style.paddingBottom = '120px';
|
||||
const rect = root.getBoundingClientRect();
|
||||
|
||||
root.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.left + 10,
|
||||
clientY: rect.bottom - 10,
|
||||
}));
|
||||
await nextTick();
|
||||
|
||||
expect(writekit.state.doc.content).toHaveLength(1);
|
||||
const sel = writekit.state.selection;
|
||||
expect(sel.kind === 'text' && sel.focus.offset).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node selection survives the DOM', () => {
|
||||
it('is represented as a real range, so selectionchange cannot overwrite it', async () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({
|
||||
registry,
|
||||
doc: createDoc([para('a', 'first paragraph'), createNode('divider', { id: 'd' })]),
|
||||
}),
|
||||
});
|
||||
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
|
||||
await nextTick();
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state).setSelection({ kind: 'node', ids: ['d'] }));
|
||||
await nextTick();
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// The selection exists in the DOM as a range around the atom's element —
|
||||
// a focused editable with NO range makes the browser invent a caret at the
|
||||
// start of the content, which used to overwrite the model's node selection.
|
||||
const domSel = getSelection()!;
|
||||
expect(domSel.rangeCount).toBe(1);
|
||||
const selected = domSel.getRangeAt(0).cloneContents().querySelector('[data-block-id="d"]');
|
||||
expect(selected).not.toBeNull();
|
||||
|
||||
// A selectionchange pass over that range must NOT rewrite the model.
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
await nextTick();
|
||||
expect(writekit.state.selection).toEqual({ kind: 'node', ids: ['d'] });
|
||||
|
||||
// Enter on the selected atom exits into a fresh paragraph below it.
|
||||
const root = document.querySelector('[data-writekit-root]') as HTMLElement;
|
||||
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
|
||||
const sel = writekit.state.selection;
|
||||
expect(sel.kind === 'text' && sel.focus.blockId).toBe(writekit.state.doc.content[2]!.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { useContextFactory, useEventListener } from '@robonen/vue';
|
||||
export { unrefElement, useContextFactory, useEventListener } from '@robonen/vue';
|
||||
|
||||
@@ -151,10 +151,25 @@ export function createSelectionBridge(
|
||||
return;
|
||||
|
||||
if (selection.kind === 'node') {
|
||||
// Block-level selection has no native text range; the visual highlight
|
||||
// comes from [data-selected] on the block wrapper. Keep the editable root
|
||||
// focused so keyboard commands (Backspace/Delete on the node) still reach it.
|
||||
// The node selection must exist in the DOM too, as a range around the
|
||||
// block element. Merely clearing the ranges left a focused editable with
|
||||
// no selection — the browser then invents a caret at the START of the
|
||||
// content, `selectionchange` reads it, and the model's node selection
|
||||
// gets overwritten by a text caret in the first block (the Enter meant
|
||||
// for the atom split the opening paragraph instead).
|
||||
domSel.removeAllRanges();
|
||||
|
||||
const lastId = selection.ids.at(-1);
|
||||
const el = lastId === undefined
|
||||
? null
|
||||
: root.querySelector(`[data-block-id="${CSS.escape(lastId)}"]`);
|
||||
|
||||
if (el) {
|
||||
const range = root.ownerDocument.createRange();
|
||||
range.selectNode(el);
|
||||
domSel.addRange(range);
|
||||
}
|
||||
|
||||
if (root.isContentEditable && root.ownerDocument.activeElement !== root)
|
||||
root.focus({ preventScroll: true });
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { onBeforeUnmount, ref, shallowRef } from 'vue';
|
||||
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
|
||||
import { isCollapsed } from '../../model';
|
||||
import { isMarkActive, toggleMark } from '../../commands';
|
||||
@@ -17,7 +17,7 @@ const ctx = useWritekitContext();
|
||||
// Virtual reference (a `Measurable`) anchored to the selection rect — Popper
|
||||
// positions against it with no trigger element. Reassigned on every refresh so
|
||||
// PopperContent re-resolves position as the selection moves.
|
||||
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
|
||||
const reference = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>();
|
||||
const open = ref(false);
|
||||
const rev = ref(0);
|
||||
|
||||
@@ -54,8 +54,11 @@ function toggle(type: string): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal to="body">
|
||||
<!-- Combobox layering: PopperRoot provides the positioning context outside
|
||||
the portal. The bare Portal resolves its target from the ConfigProvider's
|
||||
teleportTarget (body unless the app overrides it). -->
|
||||
<PopperRoot>
|
||||
<Portal>
|
||||
<PopperContent
|
||||
v-if="open && reference"
|
||||
:reference="reference"
|
||||
@@ -83,6 +86,6 @@ function toggle(type: string): void {
|
||||
</slot>
|
||||
</DismissableLayer>
|
||||
</PopperContent>
|
||||
</PopperRoot>
|
||||
</Portal>
|
||||
</PopperRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,38 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
|
||||
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
|
||||
import { createTransaction } from '../../state';
|
||||
import { useWritekitContext } from '../context';
|
||||
import { useEventListener } from '../composables';
|
||||
import type { SlashItem } from './slash-items';
|
||||
import { getSlashItems } from './slash-items';
|
||||
|
||||
export interface WritekitSlashMenuProps {
|
||||
/** Character that opens the menu (default `'/'`). */
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>();
|
||||
|
||||
const ctx = useWritekitContext();
|
||||
const open = ref(false);
|
||||
const items = ref<SlashItem[]>([]);
|
||||
const highlighted = ref(0);
|
||||
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
|
||||
// against it with no trigger element. Focus stays in the contenteditable (so the
|
||||
// user keeps typing to filter), so nav is driven by the capture-phase keydown
|
||||
// below and the highlight is an index — not roving focus / listbox focus.
|
||||
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
|
||||
|
||||
let triggerBlockId = '';
|
||||
let triggerStart = 0;
|
||||
let caretOffset = 0;
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
<script lang="ts">
|
||||
/** Regexp-special characters, escaped when the trigger is interpolated. */
|
||||
const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
|
||||
|
||||
/** The caret's client rect, when the native selection has a visible one. */
|
||||
function caretRect(): DOMRect | null {
|
||||
const selection = globalThis.window === undefined ? null : globalThis.getSelection();
|
||||
if (!selection || selection.rangeCount === 0)
|
||||
@@ -43,6 +13,60 @@ function caretRect(): DOMRect | null {
|
||||
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
|
||||
return rect.width || rect.height ? rect : null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
|
||||
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
|
||||
import { createTransaction } from '../../state';
|
||||
import { useWritekitContext } from '../context';
|
||||
import { unrefElement, useEventListener } from '../composables';
|
||||
import type { SlashItem } from './slash-items';
|
||||
import { getSlashItems } from './slash-items';
|
||||
|
||||
export interface WritekitSlashMenuProps {
|
||||
/** Character that opens the menu (default `'/'`). */
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>();
|
||||
|
||||
/**
|
||||
* Optional detail pane beside the list. Headless: the menu only knows WHICH
|
||||
* item is highlighted; what a block looks like is the app's knowledge, so the
|
||||
* pane renders the `preview` slot when given one and plain `meta.description`
|
||||
* text otherwise. No slot and no description → no pane, same menu as before.
|
||||
*/
|
||||
const slots = defineSlots<{
|
||||
preview?: (props: { item: SlashItem }) => unknown;
|
||||
}>();
|
||||
|
||||
const ctx = useWritekitContext();
|
||||
const open = ref(false);
|
||||
const items = shallowRef<SlashItem[]>([]);
|
||||
const highlighted = ref(0);
|
||||
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
|
||||
// against it with no trigger element. Focus stays in the contenteditable (so the
|
||||
// user keeps typing to filter), so nav is driven by the capture-phase keydown
|
||||
// below and the highlight is an index — not roving focus / listbox focus.
|
||||
const reference = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>();
|
||||
|
||||
// vue ≥3.5: a template ref inside v-for collects the elements in source order.
|
||||
const layer = useTemplateRef<InstanceType<typeof DismissableLayer>>('layer');
|
||||
const itemRefs = useTemplateRef<HTMLButtonElement[]>('options');
|
||||
const layerEl = computed(() => unrefElement(layer.value));
|
||||
|
||||
const active = computed<SlashItem | undefined>(() => items.value[highlighted.value]);
|
||||
const hasPreview = computed(() => slots.preview !== undefined || active.value?.description !== undefined);
|
||||
|
||||
/** `(start or whitespace) + trigger + query` immediately before the caret. */
|
||||
const matcher = computed(() =>
|
||||
new RegExp(`(?:^|\\s)${trigger.replaceAll(ESCAPE_RE, '\\$&')}([\\p{L}\\p{N}]*)$`, 'u'));
|
||||
|
||||
let triggerBlockId = '';
|
||||
let triggerStart = 0;
|
||||
let caretOffset = 0;
|
||||
|
||||
function close(): void {
|
||||
open.value = false;
|
||||
@@ -65,7 +89,7 @@ function refresh(): void {
|
||||
}
|
||||
|
||||
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset);
|
||||
const match = new RegExp(`(?:^|\\s)${escapeRegExp(trigger)}([\\p{L}\\p{N}]*)$`, 'u').exec(before);
|
||||
const match = matcher.value.exec(before);
|
||||
|
||||
if (!match) {
|
||||
close();
|
||||
@@ -149,15 +173,41 @@ function onKeydownCapture(event: KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation must chase the highlight into view — a list longer than
|
||||
// the menu's max-height otherwise walks the selection out of sight.
|
||||
watch(highlighted, index => void nextTick(() => {
|
||||
itemRefs.value?.[index]?.scrollIntoView({ block: 'nearest' });
|
||||
}));
|
||||
|
||||
/**
|
||||
* The menu is anchored to a caret rect that does NOT move with the page, so a
|
||||
* background scroll visually tears the menu off its anchor. Scrolling inside
|
||||
* the menu (a long block list) stays allowed.
|
||||
*/
|
||||
function onScrollIntent(event: Event): void {
|
||||
if (!open.value)
|
||||
return;
|
||||
|
||||
if (layerEl.value && event.target instanceof Node && layerEl.value.contains(event.target))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
ctx.writekit.on('transaction', refresh);
|
||||
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
|
||||
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'keydown', onKeydownCapture as (event: Event) => void, { capture: true });
|
||||
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'wheel', onScrollIntent, { capture: true, passive: false });
|
||||
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'touchmove', onScrollIntent, { capture: true, passive: false });
|
||||
onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal to="body">
|
||||
<!-- Combobox layering: PopperRoot provides the positioning context outside
|
||||
the portal. The bare Portal resolves its target from the ConfigProvider's
|
||||
teleportTarget (body unless the app overrides it). -->
|
||||
<PopperRoot>
|
||||
<Portal>
|
||||
<PopperContent
|
||||
v-if="open && reference"
|
||||
:reference="reference"
|
||||
@@ -167,15 +217,21 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
|
||||
:collision-padding="8"
|
||||
>
|
||||
<DismissableLayer
|
||||
ref="layer"
|
||||
class="writekit-slash"
|
||||
data-writekit-slash=""
|
||||
@dismiss="close"
|
||||
@focus-outside.prevent
|
||||
>
|
||||
<div
|
||||
class="writekit-slash-menu"
|
||||
role="listbox"
|
||||
data-writekit-slash-menu=""
|
||||
@dismiss="close"
|
||||
@focus-outside.prevent
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.type"
|
||||
ref="options"
|
||||
type="button"
|
||||
role="option"
|
||||
:data-highlighted="index === highlighted || undefined"
|
||||
@@ -186,8 +242,21 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
|
||||
<span class="slash-title">{{ item.title }}</span>
|
||||
<span class="slash-group">{{ item.group }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
v-if="active && hasPreview"
|
||||
class="writekit-slash-preview"
|
||||
data-writekit-slash-preview=""
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot name="preview" :item="active">
|
||||
<span class="slash-preview-title">{{ active.title }}</span>
|
||||
<span class="slash-preview-text">{{ active.description }}</span>
|
||||
</slot>
|
||||
</aside>
|
||||
</DismissableLayer>
|
||||
</PopperContent>
|
||||
</PopperRoot>
|
||||
</Portal>
|
||||
</PopperRoot>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface SlashItem {
|
||||
title: string;
|
||||
group: string;
|
||||
keywords: readonly string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,7 @@ export function getSlashItems(registry: Registry, query = ''): SlashItem[] {
|
||||
title: def.meta!.title,
|
||||
group: def.meta!.group ?? 'blocks',
|
||||
keywords: def.meta!.keywords ?? [],
|
||||
...(def.meta!.description !== undefined && { description: def.meta!.description }),
|
||||
}));
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user