Skip to content

Editing

How card text, fields, templates and reference chips are stored and edited; the document model underneath is in architecture.md.

A card owns two rich-text containers. The title is a single paragraph in titles, the body is a document in content, and both are mergeable maps keyed by card id inside the workspace document.

CardTitleEditor and CardBodyEditor in apps/canvas/src/cards/CardBodyEditor.tsx are the only components that mount them. The title editor is shared by the canvas card and the panel, so the Enter, blur and Escape contract has one implementation: Enter commits unless the @ picker is open and consuming the key, and an empty title draws the “Untitled” placeholder as decoration, never as text.

The split keeps the two edit sessions apart. A title is short, single-line and read on every card face; a body is long and mounted only on the surface that owns the edit. Keeping them apart means renaming a card does not touch its body container, and the two have separate undo origins.

The editor is Tiptap over ProseMirror, bound to Loro by loro-prosemirror. LoroCollaboration in apps/canvas/src/editor/LoroCollaboration.ts adds three plugins and no schema: LoroSyncPlugin mirrors one container into the editor state, LoroUndoPlugin supplies the undo stack, and LoroEphemeralCursorPlugin publishes carets on the shared cursor store. Each editor binds exactly one container, so a body edit and a title edit are separate transactions with separate origins (text:body and text:title).

The schema lives in apps/canvas/src/editor/cardEditorExtensions.ts, apart from the collaboration plugin, so editorSchemaContract.test.ts can build the same schema without a document and compare it with the publish allowlist.

Node File What it stores
paragraph, heading, headings’ lists, blockquote, codeBlock, horizontalRule, hardBreak Tiptap StarterKit Text and its block structure
mention apps/canvas/src/editor/Mention.ts A target card id, an optional field id, an optional display template
propertyBlock apps/canvas/src/editor/PropertyBlock.ts A field id and an optional display template
attachment apps/canvas/src/editor/attachmentNode.ts Metadata for bytes held beside the document
image apps/canvas/src/editor/imageLinkNode.tsx A URL, alt text and a size
tourStep apps/canvas/src/editor/TourStep.ts Step id, targets, camera, caption and advance rule

Marks are the Tiptap defaults: bold, italic, strike, code, link, underline. They are formatted from the BubbleMenu in CardBodyEditor, which reads its pressed state through useEditorState rather than re-rendering on every keystroke.

packages/graph/src/publish/validation/richTextSchema.ts is the allowlist a published body is checked against: node names, mark names and the attributes each node may carry. Adding a node or an attribute to the editor without extending it fails editorSchemaContract.test.ts, which is the point.

A publish validates against that allowlist because a share projection copies content into a different document. The mentions and blocks in a shared body resolve against the copy, so a chip can point at a card the viewer cannot open; those render as inert with the title the projection kept.

The draft control in PropertyField.tsx never writes on every keystroke. Enter blurs the input and the blur commits; Escape restores the stored value, marks the edit cancelled and blurs; a blur that follows a real change commits it, and a blur that does not ends the edit without a write. A number or date that fails to parse calls onCancel, so an invalid value is discarded rather than stored as text.

The card surface around an inline control keeps the session alive across a re-render. CardPanel and NodeCard hold the blur in a 120 ms timer and cancel it when focus comes back, because a presence update or a sync tick can blur and refocus the editor without the reader having left. Without that grace period a collaborator’s cursor movement would end the edit under the writer.

Editing is also exclusive per card. editingField and editingSurface in apps/canvas/src/store/useUiStore.ts decide which of the title, the body or the inspector owns the caret, so a card mounted on two surfaces has one editor with a caret and one read-only copy.

@ opens the mention picker in the title and in the body. apps/canvas/src/editor/SuggestionList.tsx renders it outside the React tree, which is why the keyboard rules live in its onKeyDown rather than in a component:

Key Effect
ArrowDown, ArrowUp Move the highlight, wrapping at both ends
ArrowRight or . Enter the highlighted card’s fields
ArrowLeft Leave a field list and return to its card
Enter Insert the highlighted row and close the picker
Escape Close the picker and leave the query text alone

Hovering a row moves the highlight. It never opens a submenu, because a list that changes shape under the pointer makes the next keystroke land on a different row.

Three row kinds exist. A card inserts a mention; a field of the card being edited inserts a propertyBlock, which edits that field in place; a field of another card inserts a mention carrying a fieldId. The third form is what a reader writes as @card.field, and it reads the other card’s value live.

/ opens the block menu from apps/canvas/src/editor/SlashCommands.ts. Its rows are Heading 1, Heading 2, Bulleted list, Numbered list, Quote, Code block, Divider, Image from URL, Mention, Tour step and Property.

Each row runs a command against the current block after deleting the typed /query, so the query never becomes text. The menu keeps the editor’s selection while a row is clicked: onMouseDown on the list calls preventDefault, because a focus change would collapse the range the command needs.

A row that needs an answer inserts a widget instead of opening a dialog. Image from URL is the example: ImageUrlPrompt sits in the editor, so the caret and the editing session survive the answer.

Two nodes hold a picture, and the difference is where the bytes are.

An attachment stores an id, a MIME type, dimensions and a name, while the bytes live in IndexedDB and, when a relay is configured, on the relay. A share projection copies the metadata and not the pixels, which is what keeps a shared document small; a viewer without the bytes sees the upload state instead of a broken image.

An image stores a URL, alt text and a size. It works with no relay at all, which is why a standalone build can still hold a picture.

Pasting an image runs PasteImages, which measures the image, stores the bytes locally, uploads when a relay exists, and marks the node uploaded when the upload lands. A failed upload leaves the local copy in place, so the picture survives a reload.

A card face, a search result and a shared document render the same content without an editor. CardBodyEditor takes readOnly, which leaves the placeholder out and never focuses the editor; the title editor draws “Untitled” as decorative text instead. A read-only property block closes the panel instead of opening an inline control.

Nothing about the read path formats a field by hand. Values go through formatPropertyValue, which is also what produces the unavailable and not-shared labels a share needs.

A field is a FieldDefinition: id, name, kind, required flag, order, select options, optional sub-fields and the rating scale. Definitions belong to a type. Values belong to a card, in card.properties, keyed by field id.

fieldKinds in packages/graph/src/schema.ts is the closed set:

Kind Stored value
text, date string; a date is YYYY-MM-DD and must be a real calendar day
number finite number
checkbox boolean
select option id
multiSelect array of option ids
card card id
board board id
rating number with the field’s scale
group record of sub-field id to value
list array of values or of group records

Writes go through WorkspaceDocument.setProperty(cardId, fieldId, value) and clearProperty. Passing null clears, which is different from storing an empty string: isPropertyValueEmpty treats a blank text value as unanswered, and a deleted select option stays stored while it reads as unavailable.

Sub-fields are addressed as <fieldId>:<subId> when a group or list renders them, so two groups can both hold a “Page” sub-field without colliding.

FieldValueInput in apps/canvas/src/cards/PropertyField.tsx is the one control set. It owns the parse and commit rules for text, date, number, checkbox, select, multiSelect, card and board, so the inspector, the inline property block and any future cell cannot disagree about what a date is or what clearing means.

A composing kind registers an editor instead. registerFieldKind in apps/canvas/src/field/fieldKindRegistry.tsx maps a kind name to a component, and apps/canvas/src/field/compositeKinds.tsx registers rating, group and list at module scope. Importing a module is what makes its kind exist, and PropertyField.tsx imports the composing kinds beside the switch, so a composite kind ships without editing the inspector and without each caller registering it.

A read-only surface never formats a value itself. readPropertyValue and formatPropertyValue in packages/graph/src/property.ts produce the text, including the unavailable and not-shared labels, and the registry deliberately has no format function: two formatters for one value drifted, and the group summary in the registry counted a blank sub-field as filled while the graph did not.

A display template decides how one value reads where it appears: literal text plus {{label}}, {{value}}, {{card}} and {{type}}. packages/graph/src/template.ts parses it into segments, ValueTemplate in apps/canvas/src/cards/ValueTemplate.tsx renders them with the label muted, and nothing richer is possible because a template is not markup.

Templates are stored per field (display), and a single mention or property block may carry its own override. DISPLAY_PRESETS supplies the buttons in DisplayStyleDialog; the field default is {{label}} · {{value}} for a block and @{{card}} · {{value}} for a mention chip, because a chip is a reference from somewhere else and a bare name would not say where to. The maximum length is 160 characters.

Empty values render as EMPTY_VALUE_TEXT, a single em dash, so a template never shows a dangling prefix.

A card is identity plus typeIds, properties, tags, and its two text containers. It exists without a board, and a board shows it through a placement.

Templates duplicate. Using a board template copies its cards into a new board with a name like Recipe 2, and a type blueprint is itself a card that a new type copies its fields from. Nothing keeps a link between a template and what it produced (ADR-0038, ADR-0059).

A reference is derived, never stored. packages/graph/src/references.ts walks card content for mention nodes, reads the cardId from the node’s attributes map, and adds card-valued fields, so WorkspaceDocument.referencesFor(cardId) answers “who points at this” without a relation store (ADR-0019).

Edges are separate. An arrow between two cards carries a name and a path, no payload, and no reference row (ADR-0062). That keeps “this mentions that” and “this is drawn next to that” from being the same fact.

Deleting a target leaves the mention in place. The chip reads the target live, so a missing card renders as “Unavailable card” and a projection that kept no title renders as “Not shared”; neither case silently deletes the text the reader wrote.

@ opens one picker shared by the title and the body. Rows come from apps/canvas/src/editor/SuggestionList.tsx: a card inserts a chip, a field of the current card inserts a property block, and a field of another card inserts a @card.field chip.

The chip stores the target id, the field id when there is one, and the per-mention display override. It reads the title, the field name and the value live through useCard and useCardTitle, so a rename shows up in every mention. MentionChipNodeView in apps/canvas/src/editor/MentionChip.tsx stops double-click propagation so a chip inside a card does not open the card behind it.

Clicking a chip navigates or reveals: a card target opens it, a field target opens the inspector on that field through focusTarget. Renaming, retyping or deleting the target never rewrites the chip.

Each editor owns its undo stack. LoroUndoPlugin builds an UndoManager per editor with a 1000 ms merge interval and 100 steps, and excludes origins that belong to another surface: the body excludes text:title, the title excludes text:body, and both exclude struct: and sys:init. Mod-z, Mod-y and Mod-Shift-z are bound in LoroCollaboration.

Structural edits are named struct: precisely so they stay out of the text undo stack. Creating a card, dropping a placement or removing a section commits once at the end of the gesture, so one Mod-z reverses the whole move instead of one record at a time.

Undo is therefore scoped to the surface that has focus, and it never reaches into another card’s text. What a keystroke will undo is decided by which editor the caret is in.

Concern File
Body schema and extensions apps/canvas/src/editor/cardEditorExtensions.ts
Collaboration plugins and undo apps/canvas/src/editor/LoroCollaboration.ts
Body and title editors apps/canvas/src/cards/CardBodyEditor.tsx
Field control set apps/canvas/src/cards/PropertyField.tsx
Composite kinds apps/canvas/src/field/compositeKinds.tsx
Field kind registry apps/canvas/src/field/fieldKindRegistry.tsx
Templates and presets packages/graph/src/template.ts
Value formatting packages/graph/src/property.ts
Reference index packages/graph/src/references.ts
Behaviour Test
Editor schema against the publish allowlist apps/canvas/src/editor/editorSchemaContract.test.ts
Mention node attributes and resolution apps/canvas/src/editor/Mention.test.ts
Mention chip rendering, display override apps/canvas/src/editor/MentionChip.test.tsx
Slash menu rows and commands apps/canvas/src/editor/SlashCommands.test.ts
Field controls and commit rules apps/canvas/src/cards/PropertyField.test.tsx
Composite kinds apps/canvas/src/field/compositeKinds.test.tsx
Card panel tabs and editing apps/canvas/src/cards/CardPanel.test.tsx
Card gestures and the title gesture apps/canvas/src/cards/NodeCard.test.tsx
Display templates packages/graph/src/template.test.ts
Value formatting packages/graph/src/property.test.ts

The procedure for adding a node, a mark, a field renderer or a command is in change-the-editor.md.