6 Commits

Author SHA1 Message Date
robonen 551b3bd921 fix(writekit): a node selection is a real DOM range, not an absence of one
Publish to NPM / Check version changes and publish (push) Successful in 10m0s
Selecting an atom used to clear every native range and leave the editable
root focused with NO selection. The browser then invents a caret at the
START of the content, `selectionchange` reads it, and the model's node
selection is overwritten by a text caret in the first block — so the
Enter meant to exit the freshly inserted atom split the opening
paragraph, and typing replaced its text. Reproduced live within a minute
of using the slash menu.

- the bridge now writes a node selection as `range.selectNode(blockEl)`;
  the read path maps that range to null, so selectionchange keeps its
  hands off the model
- beforeinput guards the node-selection state: browser edits through the
  element-wrapping range are prevented; delete falls through to
  deleteSelection, insertParagraph to exitAtom
- browser regression test walks the exact race: select atom → DOM range
  exists → selectionchange rewrites nothing → Enter lands a paragraph
  below the atom

writekit 0.0.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:52:54 +07:00
robonen 1d105b1f55 feat(writekit): a way out of atoms, and a slash menu that behaves
Publish to NPM / Check version changes and publish (push) Successful in 9m58s
Two gaps an author hits within the first minute of using atom blocks:

- there was no way to add a paragraph after a non-text block. Enter on a
  selected atom now starts a paragraph below it (exitAtom, chained before
  splitBlock), and a click on the root's padding below a trailing atom
  does the same — ends-in-text just places the caret at the end
- the slash menu ignored its own overflow: keyboard navigation walked the
  highlight out of view (now scrollIntoView nearest), and a background
  wheel scroll tore the menu off its caret anchor (now prevented outside
  the menu; scrolling the list itself stays native)
- BlockMeta gains `description`; the menu shows a detail pane beside the
  list with the highlighted item's description, replaceable wholesale via
  the new #preview slot. Headless stays headless: the pane is unstyled
  text and appears only when there is a description or a slot. Preset
  blocks are all described

Both floating menus (slash, bubble) are re-layered to the combobox
convention: PopperRoot provides the positioning context OUTSIDE a bare
Portal, which resolves its target from the ConfigProvider's
teleportTarget — the previous hardcoded to="body" was overriding the
app's configured target. A Combobox itself is the wrong base here on
purpose: its keyboard lives on ComboboxInput, while a suggestion menu
must leave focus in the contenteditable — the editor is the input.

writekit 0.0.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:28:06 +07:00
robonen 66d9faad22 build: bump privitives to 0.0.6 and writekit to 0.0.2
Publish to NPM / Check version changes and publish (push) Successful in 9m53s
2026-08-11 03:45:34 +07:00
robonen edcccf16d8 feat(docs): stop hiding half the component API — typed emits, exposes, flow guide
Publish to NPM / Check version changes and publish (push) Has been cancelled
The per-part regex only saw defineEmits<{ inline literal }>(), so a named
interface — how Flow, Popover, Dialog, Menu and Drawer all declare their
events — extracted as zero emits, and defineExpose was not extracted at
all: 36 components' template-ref surfaces were invisible. Consumers
rebuilt what existed (nodeDragStop from @nodes-change, a renderless
child to reach fitView).

- extractor: one type-checking project per components package with a
  virtual <file>.vue.ts mirror per SFC, so cross-file emits interfaces
  (extends included) and expose spreads resolve through the checker —
  ...api expands into the composable's full return with its JSDoc
- parts gain exposes; emits/exposes members carry their JSDoc text;
  update:* model emits get a stock description
- UI: Description column on emits, an Exposes (template ref) table; MCP
  get_doc renders the same
- FlowRoot: JSDoc on every emit and exposed member
- new primitives guide page: building flow graphs — sizing, custom
  nodes, .nodrag, click family, instance API, edge labels

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 03:04:12 +07:00
45 changed files with 1483 additions and 113 deletions
+6 -1
View File
@@ -89,7 +89,12 @@ const roleColor: Record<string, string> = {
<DocsEmitsTable :emits="part.emits" />
</div>
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
<div v-if="part.exposes?.length" class="mb-3">
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Exposes (template ref)</div>
<DocsExposesTable :exposes="part.exposes" />
</div>
<p v-if="part.props.length === 0 && part.emits.length === 0 && !part.exposes?.length" class="text-sm text-fg-subtle italic">
No props or events renders its element and forwards attributes.
</p>
</div>
+5
View File
@@ -12,6 +12,7 @@ defineProps<{
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
@@ -22,6 +23,10 @@ defineProps<{
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
</td>
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="e.description" :text="e.description" />
<span v-else></span>
</td>
</tr>
</tbody>
</table>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">import type { PropertyMeta } from '../../modules/extractor/types';
defineProps<{
exposes: PropertyMeta[];
}>();
</script>
<template>
<div v-if="exposes.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Name</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
<tr v-for="x in exposes" :key="x.name" class="border-t border-border align-top">
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-accent-text font-mono text-[13px] font-medium">{{ x.name }}</code><span v-if="x.optional" class="text-fg-subtle text-xs">?</span>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ x.type }}</code>
</td>
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="x.description" :text="x.description" />
<span v-else></span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
+152 -6
View File
@@ -12,7 +12,7 @@
import { basename, dirname, relative, resolve } from 'node:path';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { Node, Project, SyntaxKind } from 'ts-morph';
import { Node, Project, SyntaxKind, ts } from 'ts-morph';
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
import type {
CategoryMeta,
@@ -858,6 +858,144 @@ function extractScriptBlock(sfc: string, setup: boolean): string {
return '';
}
// ── SFC type project ─────────────────────────────────────────────────────────
/**
* One type-checking project per components package: every real `src/**\/*.ts`
* file plus, for each SFC part, a virtual `<file>.vue.ts` mirror holding its
* two script blocks. TS resolves a `./X.vue` specifier by appending `.ts`, so
* the mirrors make cross-file shapes resolve for real — `defineEmits<XEmits>()`
* where the interface lives in another block, a sibling `.ts` or another SFC,
* and `defineExpose({ ...api })` where the spread's type is a composable's
* return. The per-part regexes never saw any of those, which is exactly how
* half of Flow's API ended up invisible in the docs.
*/
function buildSfcProject(pkgDir: string): Project {
const srcDir = resolve(pkgDir, 'src');
const tsconfigPath = resolve(pkgDir, 'tsconfig.json');
const project = new Project({
tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
skipAddingFilesFromTsConfig: true,
});
project.addSourceFilesAtPaths([`${srcDir}/**/*.ts`, `!${srcDir}/**/__test__/**`]);
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.vue') || entry.name === 'demo.vue') continue;
const full = resolve(entry.parentPath, entry.name);
if (full.includes('__test__')) continue;
const sfc = readFileSync(full, 'utf-8');
const script = `${extractScriptBlock(sfc, false)}\n${extractScriptBlock(sfc, true)}`;
if (script.trim()) project.createSourceFile(`${full}.ts`, script, { overwrite: true });
}
return project;
}
/** Type display: keep alias names (`Ref<T>`, not its expansion), never truncate. */
const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.NoTruncation;
/** JSDoc description of a declaration; a const's doc sits on its statement. */
function describeDecl(node: Node | undefined): string {
if (!node) return '';
const holder = Node.isVariableDeclaration(node) ? node.getVariableStatement() ?? node : node;
if (!Node.isJSDocable(holder)) return '';
const jsdocs = holder.getJsDocs();
return getDescription(jsdocs, getJsDocTags(jsdocs));
}
/**
* Emits through the checker's view of `defineEmits<T>()`: the inline literal
* AND a named interface (same block, sibling `.ts`, another SFC via the
* mirrors), `extends` chains included — with each member's JSDoc.
*/
function extractEmitsFrom(sf: SourceFile): EmitMeta[] {
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(c => c.getExpression().getText() === 'defineEmits');
const typeArg = call?.getTypeArguments()[0];
if (!call || !typeArg) return [];
const emits: EmitMeta[] = [];
for (const prop of typeArg.getType().getProperties()) {
const decl = prop.getDeclarations()[0];
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
emits.push({
name: prop.getName(),
payload: cleanType(written ?? prop.getTypeAtLocation(call).getText(call, TYPE_TEXT_FLAGS)),
description: describeDecl(decl),
});
}
return emits;
}
/**
* `defineExpose({ … })` → the template-ref surface. Spreads expand through the
* checker (`...api` lists every member of the composable's return type with its
* JSDoc), so the docs show the full instance API instead of nothing at all.
*/
function extractExposesFrom(sf: SourceFile): PropertyMeta[] {
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(c => c.getExpression().getText() === 'defineExpose');
const arg = call?.getArguments()[0];
if (!arg || !Node.isObjectLiteralExpression(arg)) return [];
const out: PropertyMeta[] = [];
const push = (name: string, type: string, description: string, optional = false) => {
if (!out.some(p => p.name === name))
out.push({ name, type: cleanType(type), description, optional, defaultValue: null, readonly: false });
};
for (const member of arg.getProperties()) {
if (Node.isSpreadAssignment(member)) {
const spreadType = member.getExpression().getType();
const props = spreadType.getProperties();
// Unresolvable spread — surface it verbatim rather than dropping it.
if (spreadType.isAny() || props.length === 0) {
push(member.getText(), '', '');
continue;
}
for (const prop of props) {
const decl = prop.getDeclarations()[0];
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
push(
prop.getName(),
written ?? prop.getTypeAtLocation(member).getText(member, TYPE_TEXT_FLAGS),
describeDecl(decl),
decl !== undefined && Node.isQuestionTokenable(decl) && decl.hasQuestionToken(),
);
}
}
else if (Node.isShorthandPropertyAssignment(member)) {
const local = sf.getProject().getTypeChecker().getShorthandAssignmentValueSymbol(member);
push(
member.getName(),
member.getType().getText(member, TYPE_TEXT_FLAGS),
describeDecl(local?.getDeclarations()[0]),
);
}
else if (Node.isPropertyAssignment(member)) {
const init = member.getInitializer();
const initDecl = init && Node.isIdentifier(init) ? init.getSymbol()?.getDeclarations()[0] : undefined;
push(
member.getName().replaceAll(/^['"]|['"]$/g, ''),
(init ?? member).getType().getText(member, TYPE_TEXT_FLAGS),
describeDecl(member) || describeDecl(initDecl),
);
}
else if (Node.isMethodDeclaration(member)) {
push(member.getName(), member.getType().getText(member, TYPE_TEXT_FLAGS), describeDecl(member));
}
}
return out;
}
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
function extractEmits(setupScript: string): EmitMeta[] {
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
@@ -907,7 +1045,7 @@ function extractModels(setupScript: string): { props: PropertyMeta[]; emits: Emi
defaultValue: null,
readonly: false,
});
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: `Emitted when \`v-model${name === 'modelValue' ? '' : `:${name}`}\` updates.` });
}
return { props, emits };
@@ -968,7 +1106,7 @@ function roleFromName(componentName: string, base: string): string {
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
* is the package subpath (e.g. `./forms/checkbox`).
*/
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string, sfcProject?: Project): ComponentMeta | null {
// A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) return null;
@@ -1001,16 +1139,22 @@ function buildComponentAt(dir: string, slug: string, category: string, entryPoin
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Emits/exposes come from the typed SFC project when it has this part;
// the regex parser stays as the fallback for inline-literal emits.
const virtual = sfcProject?.getSourceFile(`${resolve(dir, file)}.ts`);
let emits = virtual ? extractEmitsFrom(virtual) : [];
if (emits.length === 0) emits = extractEmits(setup);
const exposes = virtual ? extractExposesFrom(virtual) : [];
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
parts.push({ name, role, description, props, emits, exposes });
}
return {
@@ -1030,6 +1174,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return [];
const sfcProject = buildSfcProject(pkgDir);
const components: ComponentMeta[] = [];
// Components live one level deep, in category folders: src/<category>/<component>/.
@@ -1048,13 +1193,14 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
compEntry.name,
label,
`./${catEntry.name}/${compEntry.name}`,
sfcProject,
);
if (c) components.push(c);
}
}
else {
// Backward-compat: a flat component dir directly under src.
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`, sfcProject);
if (c) components.push(c);
}
}
+5
View File
@@ -142,6 +142,11 @@ export interface ComponentPartMeta {
props: PropertyMeta[];
/** Emitted events parsed from `defineEmits` */
emits: EmitMeta[];
/**
* The template-ref surface parsed from `defineExpose`, spreads expanded
* through the type checker (`...api` lists the composable's whole return).
*/
exposes?: PropertyMeta[];
}
export interface EmitMeta {
+5
View File
@@ -226,6 +226,11 @@ function renderComponentPart(part: ComponentPartMeta): string[] {
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
}
if (part.exposes && part.exposes.length > 0) {
const rows = part.exposes.map(x => [cell(x.name), cell(`\`${x.type}\``), cell(x.description)]);
out.push('#### Exposes (template ref)', '', table(['Name', 'Type', 'Description'], rows), '');
}
return out;
}
+191
View File
@@ -0,0 +1,191 @@
<!-- title: Building flow graphs -->
<script setup lang="ts">
// Prose + snippets only — static content, prerenders cleanly.
const minimal = `<script setup lang="ts">
import { FlowBackground, FlowControls, FlowRoot } from '@robonen/primitives';
import type { FlowEdge, FlowNode } from '@robonen/primitives';
const nodes: FlowNode[] = [
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'Start' } },
{ id: 'b', position: { x: 260, y: 120 }, data: { label: 'Finish' } },
];
const edges: FlowEdge[] = [
{ id: 'a-b', source: 'a', target: 'b', label: 'then' },
];
<\/script>
<template>
<!-- The pane fills this element — give it a real height. -->
<div style="height: 480px">
<FlowRoot :default-nodes="nodes" :default-edges="edges" fit-view-on-mount>
<template #node-default="{ node }">
<div class="card">{{ node.data.label }}</div>
</template>
<FlowBackground />
<FlowControls />
</FlowRoot>
</div>
</template>`;
const customNode = `<!-- Register per-type renderers via nodeTypes (module-level map)… -->
<FlowRoot :node-types="{ scene: SceneNode }" … />
<!-- …or inline via a #node-<type> scoped slot: -->
<FlowRoot :default-nodes="nodes">
<template #node-scene="{ node, selected }">
<article :data-selected="selected" class="scene">
<h4>{{ node.data.title }}</h4>
<!-- One SOURCE handle per row: anchor it to the row, not the side's
midpoint — same-position handles of one type otherwise overlap. -->
<div v-for="option in node.data.options" :key="option.id" class="row">
{{ option.label }}
<FlowHandle
:id="'opt:' + option.id"
type="source"
position="right"
class="row-port"
/>
</div>
</article>
</template>
</FlowRoot>`;
const nodrag = `<!-- Form controls inside a node already win over dragging:
input, textarea, select, button, [contenteditable], [data-handleid]
start no drag. Everything else opts out with the .nodrag class: -->
<template #node-scene="{ node }">
<div class="scene">
<button @click="open(node.id)">Edit</button> <!-- just works -->
<div class="nodrag">
<MyColorWheel /> <!-- opted out -->
</div>
</div>
</template>`;
const events = `<FlowRoot
:default-nodes="nodes"
@node-click="(id) => select(id)"
@node-double-click="(id) => openEditor(id)"
@node-drag-stop="(ids) => persistPositions(ids)"
@pane-click="clearInspector()"
@edge-click="(id) => selectEdge(id)"
/>`;
const instance = `<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { FlowRoot } from '@robonen/primitives';
const flow = useTemplateRef('flow');
function frameSelection(ids: string[]) {
flow.value?.fitView({ padding: 0.2, nodes: ids });
}
function addAtCursor(event: MouseEvent) {
const position = flow.value!.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// …push a node at \`position\`
}
<\/script>
<template>
<FlowRoot ref="flow" :default-nodes="nodes" fit-view-on-mount />
</template>`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Building flow graphs</h1>
<p>
<code>Flow</code> is a headless node-and-edge canvas: panning, zooming,
dragging, connecting, selection and virtualization are handled for you;
every pixel of a node is yours. This guide covers the contracts that are
easy to miss: sizing, custom nodes, drag opt-out, events and the
imperative API.
</p>
<h2>A minimal graph</h2>
<p>
The pane fills its nearest sized ancestor the graph lives in
absolutely-positioned layers, so the <em>host</em> element must have a
real height. <code>fit-view-on-mount</code> frames the graph once nodes
are measured; it is skipped when you control the viewport yourself
(<code>v-model:viewport</code> / <code>defaultViewport</code>).
</p>
</div>
<DocsCode :code="minimal" lang="vue" />
<div class="prose-docs">
<h2>Custom nodes</h2>
<p>
Nodes render through a component map (<code>nodeTypes</code>, keyed by
<code>node.type</code>) or a <code>#node-&lt;type&gt;</code> scoped
slot. The slot receives the internal node (<code>node.data</code> is
yours) and its <code>selected</code> state. Place
<code>FlowHandle</code>s anywhere inside give repeated same-side
handles their own anchors, since handles of one type default to the
side's midpoint and would overlap.
</p>
</div>
<DocsCode :code="customNode" lang="vue" />
<div class="prose-docs">
<h2>Interactive content and <code>.nodrag</code></h2>
<p>
The drag layer owns <code>pointerdown</code> on the node. Native form
controls (<code>input</code>, <code>textarea</code>, <code>select</code>,
<code>button</code>), <code>[contenteditable]</code> elements and
handles are excluded automatically; any other interactive element opts
out of dragging with the <code>.nodrag</code> class.
</p>
</div>
<DocsCode :code="nodrag" lang="vue" />
<div class="prose-docs">
<h2>Click, double-click, drag</h2>
<p>
The drag layer distinguishes a settled click from a drag, so
<code>@node-click</code> never fires after a real move, and
<code>@node-double-click</code> pairs two settled clicks double-click
on a node does <em>not</em> zoom the canvas. Positions are persisted
from <code>@node-drag-stop</code>, which reports every node that moved.
</p>
</div>
<DocsCode :code="events" lang="vue" />
<div class="prose-docs">
<h2>The instance API</h2>
<p>
<code>FlowRoot</code> exposes its whole imperative surface through the
template ref <code>fitView</code>, zooming, viewport get/set,
coordinate conversion (<code>screenToFlowPosition</code> /
<code>flowToScreenPosition</code>), node/edge lookups and selection
control. The full list is on the <code>Flow</code> component page under
<em>Exposes</em>.
</p>
</div>
<DocsCode :code="instance" lang="vue" />
<div class="prose-docs">
<h2>Edge labels</h2>
<p>
An edge with a <code>label</code> renders it at the path midpoint as
<code>[data-flow-edge-label]</code>, haloed with
<code>--flow-edge-label-halo</code> (defaults to white) so it stays
readable over the wire. For richer labels, take over the edge with
<code>edgeTypes</code> or an <code>#edge-&lt;type&gt;</code> slot.
</p>
</div>
</div>
</template>
+1 -1
View File
@@ -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 -1
View File
@@ -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"
+17 -1
View File
@@ -134,13 +134,14 @@ function onPointerdown(event: PointerEvent): void {
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
event.stopPropagation();
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
ctx.emitEdgeClick(id, event);
}
</script>
<template>
<g
v-if="endpoints"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, edge?.label, markerStartRef, markerEndRef]"
data-flow-edge=""
:data-id="id"
:data-type="resolvedType"
@@ -176,6 +177,21 @@ function onPointerdown(event: PointerEvent): void {
:style="interactionPathStyle"
@pointerdown="onPointerdown"
/>
<!-- The halo (paint-order + stroke) keeps the text legible over the
path and the background without the consumer styling anything. -->
<text
v-if="edge?.label"
data-flow-edge-label=""
:x="path[1]"
:y="path[2]"
text-anchor="middle"
dominant-baseline="middle"
fill="currentColor"
stroke="var(--flow-edge-label-halo, white)"
stroke-width="3"
paint-order="stroke"
:style="{ pointerEvents: 'none', fontSize: '12px' }"
>{{ edge.label }}</text>
</template>
</g>
</template>
+13 -2
View File
@@ -65,8 +65,10 @@ useKeyboard(currentElement, ctx, useViewportApi(ctx));
useEventListener(currentElement, 'click', (event: MouseEvent) => {
const target = event.target as Element | null;
if (target && !target.closest('[data-flow-node],[data-flow-edge]'))
if (target && !target.closest('[data-flow-node],[data-flow-edge]')) {
ctx.clearSelection();
ctx.emitPaneClick(event as PointerEvent);
}
});
</script>
@@ -79,7 +81,16 @@ useEventListener(currentElement, 'click', (event: MouseEvent) => {
:data-interactive="ctx.interactive.value ? '' : undefined"
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
:style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }"
:style="{
position: 'relative',
overflow: 'hidden',
touchAction: 'none',
// Everything inside is absolutely positioned, so content-sizing always
// collapsed to 0×N and the graph rendered into an invisible strip.
// Vue merges a consumer's style attr over this, so it stays overridable.
width: '100%',
height: '100%',
}"
>
<slot />
+3 -1
View File
@@ -28,7 +28,9 @@ const { forwardRef } = useForwardExpose();
const style = computed<CSSProperties>(() => {
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' };
// Above the viewport's explicit layer (zIndex 1): a positioned sibling
// with z-index auto would otherwise paint underneath the graph.
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all', zIndex: 2 };
s[v] = '0';
if (h === 'center') {
s.left = '50%';
+76 -1
View File
@@ -73,26 +73,46 @@ export interface FlowRootProps extends PrimitiveProps {
isValidConnection?: IsValidConnection;
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
onlyRenderVisibleElements?: boolean;
/**
* Frame the whole graph once after the initial nodes are measured. Skipped
* when an explicit `viewport` / `defaultViewport` is provided a restored
* viewport must not be stomped by a fit. With virtualization the fit uses
* whatever is measured plus declared node sizes; fully unmeasured nodes are
* framed by position alone. @default false
*/
fitViewOnMount?: boolean | FitViewParams;
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
virtualizationBuffer?: number;
}
export interface FlowRootEmits {
/** Granular node mutations (position, selection, removal) — apply them to your controlled state. */
nodesChange: [changes: NodeChange[]];
/** Granular edge mutations (selection, removal). */
edgesChange: [changes: EdgeChange[]];
/** A connection gesture completed between two handles. */
connect: [connection: Connection];
/** A connection gesture started from a handle. */
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
/** The connection gesture ended, successfully or not. */
connectEnd: [];
/** A node drag finished; ids of every node that moved. */
nodeDragStop: [ids: string[]];
/** The set of selected nodes/edges changed. */
selectionChange: [selection: { nodes: string[]; edges: string[] }];
/** A click landed on the empty pane — not on a node or an edge. */
paneClick: [event: PointerEvent];
/** A settled click on a node (a drag that never started moving). */
nodeClick: [id: string, event: PointerEvent];
/** Two settled clicks on the same node within the double-click interval. */
nodeDoubleClick: [id: string, event: PointerEvent];
/** A click on an edge path. */
edgeClick: [id: string, event: PointerEvent];
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { computed, getCurrentInstance, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { useId } from '@robonen/vue';
import FlowPane from './FlowPane.vue';
import FlowViewport from './FlowViewport.vue';
@@ -124,6 +144,7 @@ const {
disableKeyboardA11y = false,
isValidConnection,
onlyRenderVisibleElements = false,
fitViewOnMount = false,
virtualizationBuffer = 200,
as = 'div',
} = defineProps<FlowRootProps>();
@@ -135,6 +156,7 @@ const flowId = useId(undefined, 'flow').value;
// models (controlled + uncontrolled)
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
/** Current nodes (controlled `v-model:nodes` or internal state). */
const nodes = defineModel<FlowNode[]>('nodes', {
get: external => external ?? localNodes.value,
set: (value) => {
@@ -144,6 +166,7 @@ const nodes = defineModel<FlowNode[]>('nodes', {
});
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
/** Current edges (controlled `v-model:edges` or internal state). */
const edges = defineModel<FlowEdge[]>('edges', {
get: external => external ?? localEdges.value,
set: (value) => {
@@ -153,6 +176,7 @@ const edges = defineModel<FlowEdge[]>('edges', {
});
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
/** Current viewport (controlled `v-model:viewport` or internal state). */
const viewport = defineModel<Viewport>('viewport', {
get: external => external ?? localViewport.value,
set: (value) => {
@@ -168,6 +192,7 @@ const viewport = defineModel<Viewport>('viewport', {
// would never visually update).
const nodeLookup = shallowRef(new Map<string, InternalNode>());
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
/** Selected node/edge id sets. */
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
const isDragging = shallowRef(false);
@@ -330,6 +355,7 @@ function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNod
// pick up the fresh measurement / handle geometry.
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
triggerRef(nodeLookup);
maybeFitOnMount();
}
function updateNode(id: string, patch: Partial<FlowNode>): void {
@@ -346,6 +372,7 @@ function emitSelection(): void {
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
}
/** Select a node — replacing the selection, or adding to it. */
function selectNode(id: string, additive = false): void {
if (!elementsSelectable) return;
const sel = selection.value;
@@ -357,6 +384,7 @@ function selectNode(id: string, additive = false): void {
emitSelection();
}
/** Select an edge — replacing the selection, or adding to it. */
function selectEdge(id: string, additive = false): void {
if (!elementsSelectable) return;
const sel = selection.value;
@@ -368,17 +396,20 @@ function selectEdge(id: string, additive = false): void {
emitSelection();
}
/** Replace the selection with exactly these nodes and edges. */
function setSelection(nodeIds: string[], edgeIds: string[]): void {
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
emitSelection();
}
/** Deselect everything. */
function clearSelection(): void {
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
selection.value = { nodes: new Set(), edges: new Set() };
emitSelection();
}
/** Remove every selected node (with its edges) and selected edge. */
function removeSelected(): void {
const sel = selection.value;
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
@@ -515,12 +546,56 @@ const context: FlowContext = {
endConnection,
emitNodesChange: changes => emit('nodesChange', changes),
emitEdgesChange: changes => emit('edgesChange', changes),
emitNodeClick: (id, event) => emit('nodeClick', id, event),
emitNodeDoubleClick: (id, event) => emit('nodeDoubleClick', id, event),
emitEdgeClick: (id, event) => emit('edgeClick', id, event),
emitPaneClick: event => emit('paneClick', event),
};
provideFlowContext(context);
// Imperative API, also exposed so consumers can drive the flow via a template ref.
const api = useViewportApi(context);
// fitViewOnMount
// A viewport the consumer controls (v-model:viewport) or seeds
// (defaultViewport) is restored state; a fit must never stomp it. Model
// getters fall back to a local default, so controlledness is read off the
// vnode, not the value.
const vnodeProps = getCurrentInstance()?.vnode.props ?? {};
let fitOnMountPending = fitViewOnMount !== false
&& defaultViewport === undefined
&& !('viewport' in vnodeProps)
&& !('onUpdate:viewport' in vnodeProps);
/**
* Armed until it fires once: waits for every RENDERED node to report a
* measurement fitting to unmeasured nodes fits to nothing. Under
* virtualization only the rendered subset ever measures; the rest contribute
* their declared or positional bounds through `fitView` itself.
*/
function maybeFitOnMount(): void {
if (!fitOnMountPending) return;
// Nodes can finish measuring before the pane has a size (or the reverse);
// the shot must not burn against a 0×0 container, so both gates hold it and
// the pane-rect watcher below re-arms the attempt.
const rect = paneRect.value;
if (rect.width === 0 || rect.height === 0) return;
const map = nodeLookup.value;
if (map.size === 0) return;
for (const id of visibleNodeIds.value) {
const n = map.get(id);
if (n && n.measured.width === 0 && n.measured.height === 0) return;
}
fitOnMountPending = false;
api.fitView(typeof fitViewOnMount === 'object' ? fitViewOnMount : undefined);
}
watch(paneRect, maybeFitOnMount);
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
@@ -42,6 +42,9 @@ const transform = computed(() => {
left: '0',
width: '100%',
height: '100%',
// The slot (background, panels) renders after this element; explicit
// layers keep the graph above the background and below the chrome.
zIndex: 1,
transformOrigin: '0 0',
transform,
willChange: ctx.isInteracting.value ? 'transform' : undefined,
@@ -0,0 +1,191 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FlowEdge, FlowNode } from '../index';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { h, nextTick } from 'vue';
import { FlowBackground, FlowPanel, FlowRoot } from '../index';
/**
* Regressions found by building a real story-map consumer: the pane rendered
* into zero area, the background painted over the graph, edge labels never
* rendered, the declared click emits never fired, and dblclick on a node
* zoomed the canvas. Each test pins the fixed contract.
*/
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
const nodes: FlowNode[] = [
{ id: 'a', position: { x: 0, y: 0 } },
{ id: 'b', position: { x: 300, y: 200 } },
];
function pointer(el: Element, type: string, x = 10, y = 10) {
el.dispatchEvent(new PointerEvent(type, { button: 0, pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true }));
}
/** The pane sizes to its parent; give the test-utils wrapper a real box. */
function sizeWrapper(w: VueWrapper<any>, width = 600, height = 400) {
const el = w.element as HTMLElement;
el.style.width = `${width}px`;
el.style.height = `${height}px`;
}
const edges: FlowEdge[] = [
{ id: 'a-b', source: 'a', target: 'b', label: 'take me' },
];
function flow(props: Record<string, unknown> = {}, slots: Record<string, unknown> = {}) {
return track(mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: nodes, defaultEdges: edges, ...props },
slots: { 'node-default': () => h('div', { style: 'width:120px;height:40px' }, 'n'), ...slots },
}));
}
describe('pane sizing', () => {
it('fills its parent instead of collapsing to zero height', () => {
const w = flow();
sizeWrapper(w);
const pane = w.find('[data-flow-pane]').element as HTMLElement;
// All pane content is absolutely positioned; without an own height the
// whole graph rendered inside an invisible 0px strip.
expect(pane.clientHeight).toBe(400);
});
});
describe('stacking', () => {
it('layers background under the graph and panels above it', () => {
const w = flow({}, {
default: () => [h(FlowBackground), h(FlowPanel, { position: 'top-right' }, () => 'p')],
});
const viewport = (w.find('[data-flow-viewport]').element as HTMLElement).style.zIndex;
const background = (w.find('[data-flow-background]').element as HTMLElement).style.zIndex;
const panel = (w.find('[data-flow-panel]').element as HTMLElement).style.zIndex;
// The slot chrome renders AFTER the viewport in DOM order; without these
// layers the background dots painted over every node.
expect(Number(background)).toBeLessThan(Number(viewport));
expect(Number(panel)).toBeGreaterThan(Number(viewport));
});
});
describe('edge labels', () => {
it('renders the label the type always promised', async () => {
const w = flow();
await nextTick();
const label = w.find('[data-flow-edge-label]');
expect(label.exists()).toBe(true);
expect(label.text()).toBe('take me');
});
it('renders no label element when there is none', async () => {
const w = flow({ defaultEdges: [{ id: 'a-b', source: 'a', target: 'b' }] });
await nextTick();
expect(w.find('[data-flow-edge-label]').exists()).toBe(false);
});
});
describe('the click family', () => {
async function settle(w: VueWrapper<any>, selector: string, times = 1, gap = 50) {
const el = w.find(selector).element;
for (let index = 0; index < times; index++) {
pointer(el, 'pointerdown');
pointer(el, 'pointerup');
await nextTick();
if (gap)
await new Promise(resolve => setTimeout(resolve, gap));
}
}
it('emits nodeClick for a settled click', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]');
expect(w.emitted('nodeClick')?.[0]?.[0]).toBe('a');
});
it('pairs two settled clicks into nodeDoubleClick', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]', 2, 40);
expect(w.emitted('nodeDoubleClick')?.[0]?.[0]).toBe('a');
});
it('emits paneClick only for background clicks', async () => {
const w = flow();
await nextTick();
await w.find('[data-flow-pane]').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
await w.find('[data-flow-node][data-id="a"] div').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
});
it('emits edgeClick when the edge is picked', async () => {
const w = flow();
await nextTick();
pointer(w.findAll('[data-flow-edge] path')[1]!.element, 'pointerdown');
await nextTick();
expect(w.emitted('edgeClick')?.[0]?.[0]).toBe('a-b');
});
it('does not zoom on a node double click', async () => {
const w = flow();
await nextTick();
const before = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
await w.find('[data-flow-node][data-id="a"]').trigger('dblclick');
await nextTick();
// The gesture belongs to the node (nodeDoubleClick), not the camera.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform).toBe(before);
});
});
describe('fitViewOnMount', () => {
it('frames the graph once nodes are measured', async () => {
const w = flow({ fitViewOnMount: true });
sizeWrapper(w);
await vi.waitFor(() => {
const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
expect(t).not.toBe('translate(0px, 0px) scale(1)');
});
});
it('never stomps a consumer-controlled viewport', async () => {
const w = flow({
fitViewOnMount: true,
viewport: { x: 17, y: 23, zoom: 1.5 },
'onUpdate:viewport': () => {},
});
await new Promise(resolve => setTimeout(resolve, 120));
// A bound viewport is restored state; the fit must skip it entirely.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform)
.toBe('translate(17px, 23px) scale(1.5)');
});
});
@@ -35,6 +35,9 @@ export interface NodeDragOptions {
/** Elements inside a node that must not initiate a drag. */
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
/** Two settled clicks within this window read as a double click. */
const DOUBLE_CLICK_MS = 350;
/**
* Pointer-capture node drag. Moves the node (and every co-selected node) by the
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
@@ -57,6 +60,7 @@ export function useNodeDrag(
let startX = 0;
let startY = 0;
let started = false;
let lastClickAt = 0;
let lastX = 0;
let lastY = 0;
let rafId: number | null = null;
@@ -150,6 +154,26 @@ export function useNodeDrag(
if (started) {
flush();
ctx.commitNodeDrag();
lastClickAt = 0;
}
else if (snapshot.size > 0) {
// The pointer never crossed the drag threshold: this is a click. The
// pane cannot see it (propagation stopped on pointerdown), so the node
// is the only place that can report it — and pair two settled clicks
// into a double click.
const id = toValue(nodeId);
ctx.emitNodeClick(id, event);
const now = event.timeStamp;
if (now - lastClickAt <= DOUBLE_CLICK_MS) {
ctx.emitNodeDoubleClick(id, event);
lastClickAt = 0;
}
else {
lastClickAt = now;
}
}
pointerId = -1;
started = false;
@@ -158,7 +158,9 @@ export function usePanZoom(
// ── double-click zoom ──────────────────────────────────────────────────────
useEventListener(target, 'dblclick', (event: MouseEvent) => {
if (!zoomOnDoubleClick || !ctx.interactive.value) return;
if (event.target instanceof Element && event.target.closest('.nopan')) return;
// A double click on a node belongs to the node (nodeDoubleClick), not
// to the zoom gesture.
if (event.target instanceof Element && event.target.closest('.nopan, [data-flow-node]')) return;
const vp = current();
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
if (newZoom === vp.zoom) return;
@@ -121,6 +121,10 @@ export interface FlowContext {
// ── change emission ──────────────────────────────────────────────────────
emitNodesChange: (changes: NodeChange[]) => void;
emitEdgesChange: (changes: EdgeChange[]) => void;
emitNodeClick: (id: string, event: PointerEvent) => void;
emitNodeDoubleClick: (id: string, event: PointerEvent) => void;
emitEdgeClick: (id: string, event: PointerEvent) => void;
emitPaneClick: (event: PointerEvent) => void;
}
const flow = useContextFactory<FlowContext>('FlowContext');
+1 -1
View File
@@ -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": [
+1 -1
View File
@@ -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.' },
});
+1 -1
View File
@@ -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.' },
});
+1 -1
View File
@@ -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.' },
});
+1 -1
View File
@@ -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.' },
});
+1 -1
View File
@@ -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 16.' },
});
+1 -1
View File
@@ -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.' },
});
+11 -5
View File
@@ -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.' });
+1 -1
View File
@@ -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);
+31
View File
@@ -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;
+3 -1
View File
@@ -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),
+11 -1
View File
@@ -4,6 +4,9 @@ import type { NodeSpec } from '../schema';
import type { CommandFactory } from '../state/command';
import type { InputRuleSpec } from './input-rule';
/** A lazy block component: resolved by the view on first render. */
export type BlockComponentLoader = () => Promise<Component | { default: Component }>;
/** Props passed to an atom/void block's Vue `component`. */
export interface BlockComponentProps {
/** The block's model node (read its `attrs`). */
@@ -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' });
});
});
+42 -8
View File
@@ -14,27 +14,61 @@ export interface Schema {
markSpec: (type: string) => MarkSpec | undefined;
/** Default attrs for a block type (all defaults applied). */
defaultAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a block type. */
/** Fill defaults, run `validate`, keep unknown keys for a block type. */
coerceAttrs: (type: string, attrs?: Attrs) => Attrs;
/** Default attrs for a mark type. */
defaultMarkAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a mark type. */
/** Fill defaults, run `validate`, keep unknown keys for a mark type. */
coerceMarkAttrs: (type: string, attrs?: Attrs) => Attrs;
}
/**
* Coercion fills defaults and enforces `validate`; it is NOT a whitelist.
*
* Unknown keys pass through verbatim: a document round-tripping through the
* editor must never lose fields this schema version does not know about
* dropping them silently erased consumer data (a `condition` attribute the
* spec forgot to declare disappeared on the first normalization pass and the
* loss was autosaved). Parse rules build attrs explicitly, so pasted markup
* cannot smuggle arbitrary keys through this path.
*
* A provided value failing its `validate` falls back to the declared default:
* deterministic for CRDT replicas (given one spec), loud in dev, and never a
* silently-kept invalid value.
*/
function coerceWithSpec(spec: AttrsSpec | undefined, attrs?: Attrs): Attrs {
if (!spec)
return {};
if (!spec) {
return attrs ? { ...attrs } : {};
}
const result: Record<string, AttrValue> = {};
if (attrs) {
for (const key in attrs) {
if (attrs[key] !== undefined && !(key in spec))
result[key] = attrs[key]!;
}
}
for (const key in spec) {
const attr = spec[key]!;
const provided = attrs?.[key];
if (provided !== undefined)
result[key] = provided;
else if (spec[key]!.default !== undefined)
result[key] = spec[key]!.default!;
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 (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);
});
});
+76 -3
View File
@@ -15,6 +15,14 @@ export interface HistoryEntry {
export interface HistoryOptions {
/** Maximum number of undo entries to retain (default 200). */
readonly maxSize?: number;
/**
* Coalesce a new entry into the previous one when both are plain typing in
* the same block and land within this window (ms). One keystroke per
* transaction otherwise makes Ctrl+Z a character-by-character crawl, and a
* short paragraph evicts the whole earlier history through `maxSize`.
* `0` disables coalescing. @default 500
*/
readonly coalesceMs?: number;
}
/**
@@ -26,6 +34,13 @@ export interface HistoryOptions {
export interface History {
/** Record a new edit, clearing the redo stack. */
record: (entry: HistoryEntry) => void;
/**
* Break the coalescing chain: the next recorded entry starts its own group.
* Called for anything that lands between recordings (a remote change, an
* undo/redo, a selection jump) merging across such a boundary would splice
* foreign state into one undo press.
*/
interrupt: () => void;
/** Pop the latest undo entry (and push it onto the redo stack). */
undo: () => HistoryEntry | undefined;
/** Pop the latest redo entry (and push it back onto the undo stack). */
@@ -35,18 +50,75 @@ export interface History {
clear: () => void;
}
/** Plain typing: text-only steps confined to a single block. */
function typingBlockOf(steps: readonly Step[]): string | null {
let block: string | null = null;
for (const step of steps) {
if (step.type !== 'insertInline' && step.type !== 'deleteText' && step.type !== 'replaceInline')
return null;
if (block === null)
block = step.blockId;
else if (block !== step.blockId)
return null;
}
return block;
}
export function createHistory(options: HistoryOptions = {}): History {
const maxSize = options.maxSize ?? 200;
const coalesceMs = options.coalesceMs ?? 500;
const undoStack: HistoryEntry[] = [];
const redoStack: HistoryEntry[] = [];
let lastRecordAt = 0;
let lastTypingBlock: string | null = null;
/**
* Concatenation preserves the replay invariant: `inverted` is stored in
* application order and replayed reversed, so a merged entry undoes the
* later keystrokes first exactly as separate entries would, in one press.
*/
function coalesce(top: HistoryEntry, entry: HistoryEntry): HistoryEntry {
return {
steps: [...top.steps, ...entry.steps],
inverted: [...top.inverted, ...entry.inverted],
selectionBefore: top.selectionBefore,
selectionAfter: entry.selectionAfter,
};
}
return {
record(entry) {
undoStack.push(entry);
if (undoStack.length > maxSize)
undoStack.shift();
const now = Date.now();
const block = typingBlockOf(entry.steps);
const top = undoStack[undoStack.length - 1];
const mergeable
= coalesceMs > 0
&& top !== undefined
&& block !== null
&& block === lastTypingBlock
&& now - lastRecordAt <= coalesceMs;
if (mergeable) {
undoStack[undoStack.length - 1] = coalesce(top, entry);
}
else {
undoStack.push(entry);
if (undoStack.length > maxSize)
undoStack.shift();
}
lastRecordAt = now;
lastTypingBlock = block;
redoStack.length = 0;
},
interrupt() {
lastTypingBlock = null;
},
undo() {
const entry = undoStack.pop();
if (entry)
@@ -64,6 +136,7 @@ export function createHistory(options: HistoryOptions = {}): History {
clear() {
undoStack.length = 0;
redoStack.length = 0;
lastTypingBlock = null;
},
};
}
+5
View File
@@ -63,6 +63,11 @@ export function createWritekit(options: CreateWritekitOptions): Writekit {
selectionAfter: next.selection,
});
}
else {
// Anything that lands between recordings — a remote setDoc, undo/redo, a
// selection-only move — is a boundary the coalescer must not merge over.
history.interrupt();
}
bus.emit('transaction', tr, next, prev);
if (next.doc !== prev.doc)
+27 -3
View File
@@ -3,8 +3,9 @@ import type { Attrs, Node } from '../model';
</script>
<script setup lang="ts">
import type { IntrinsicElementAttributes } from 'vue';
import { computed } from 'vue';
import type { Component, IntrinsicElementAttributes } from 'vue';
import type { BlockDefinition } from '../registry';
import { computed, defineAsyncComponent } from 'vue';
import { nodeSelection } from '../model';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
@@ -21,7 +22,30 @@ const ctx = useWritekitContext();
const def = computed(() => ctx.registry.getBlock(block.type));
const wrapperTag = computed<keyof IntrinsicElementAttributes>(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes);
const isText = computed(() => def.value?.spec.content.kind === 'text');
const atomComponent = computed(() => def.value?.component);
/**
* A function-shaped `component` is a lazy loader; wrap it once per definition
* so repeated renders reuse the same async component (and its resolved state)
* instead of re-importing per block instance.
*/
const asyncCache = new WeakMap<() => Promise<unknown>, Component>();
function resolveComponent(raw: BlockDefinition['component']): Component | undefined {
if (typeof raw !== 'function' || (raw as Component & { render?: unknown }).render || (raw as { setup?: unknown }).setup)
return raw as Component | undefined;
const loader = raw as () => Promise<Component | { default: Component }>;
let wrapped = asyncCache.get(loader);
if (!wrapped) {
wrapped = defineAsyncComponent(() =>
loader().then(m => ('default' in m ? m.default : m) as Component));
asyncCache.set(loader, wrapped);
}
return wrapped;
}
const atomComponent = computed(() => resolveComponent(def.value?.component));
const isSelected = computed(() => {
const sel = ctx.state.value.selection;
return sel.kind === 'node' && sel.ids.includes(block.id);
+52 -2
View File
@@ -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
View File
@@ -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">
<PopperRoot>
<!-- 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>
</Portal>
</PopperRoot>
</template>
+123 -54
View File
@@ -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">
<PopperRoot>
<!-- 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,27 +217,46 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
:collision-padding="8"
>
<DismissableLayer
class="writekit-slash-menu"
role="listbox"
data-writekit-slash-menu=""
ref="layer"
class="writekit-slash"
data-writekit-slash=""
@dismiss="close"
@focus-outside.prevent
>
<button
v-for="(item, index) in items"
:key="item.type"
type="button"
role="option"
:data-highlighted="index === highlighted || undefined"
:aria-selected="index === highlighted"
@mousedown.prevent="selectItem(item)"
@mousemove="highlighted = index"
<div
class="writekit-slash-menu"
role="listbox"
data-writekit-slash-menu=""
>
<span class="slash-title">{{ item.title }}</span>
<span class="slash-group">{{ item.group }}</span>
</button>
<button
v-for="(item, index) in items"
:key="item.type"
ref="options"
type="button"
role="option"
:data-highlighted="index === highlighted || undefined"
:aria-selected="index === highlighted"
@mousedown.prevent="selectItem(item)"
@mousemove="highlighted = index"
>
<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>
</Portal>
</PopperRoot>
</template>
+2
View File
@@ -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();