Editing
How card text, fields, templates and reference chips are stored and edited; the document model underneath is in architecture.md.
Title and body
Section titled “Title and body”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 rich-text model
Section titled “The rich-text model”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.
Reading and committing a value
Section titled “Reading and committing a value”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.
The @ picker
Section titled “The @ picker”@ 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.
The / menu
Section titled “The / menu”/ 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.
Images and attachments
Section titled “Images and attachments”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.
Read-only surfaces
Section titled “Read-only surfaces”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.
Fields
Section titled “Fields”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.
Rendering a field
Section titled “Rendering a field”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.
Display templates
Section titled “Display templates”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.
Cards and templates
Section titled “Cards and templates”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).
References
Section titled “References”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.
Mention chips
Section titled “Mention chips”@ 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.
Undo boundaries
Section titled “Undo boundaries”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.
Where the pieces live
Section titled “Where the pieces live”| 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 |
What covers this
Section titled “What covers this”| 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.