Change the editor
Add a node, a mark, a field renderer, a slash command or a mention action to the card editor, with the files each one touches and the tests that fail when it is half done.
The editor is Tiptap over ProseMirror, bound to Loro per container. Two rules shape every change below: the schema is declared once in apps/canvas/src/editor/cardEditorExtensions.ts so the publish allowlist can be checked against it, and the publish allowlist in packages/graph/src/publish/validation/richTextSchema.ts is the contract a shared body is validated against.
Add a node
Section titled “Add a node”- Create the node in
apps/canvas/src/editor/. A node with a React view needs aNode.createwithaddAttributes,parseHTMLandrenderHTML, plus aReactNodeViewRendererpointing at the view component.apps/canvas/src/editor/imageLinkNode.tsxis the smallest complete example;apps/canvas/src/editor/attachmentNode.tsshows one whose bytes live elsewhere. - Store ids and sizes in attributes, never content. A node that holds a document id reads its target live, which is what keeps a rename or a delete from being copied into every body.
- Add the node to
cardBodySchemaExtensionsinapps/canvas/src/editor/cardEditorExtensions.ts. The title editor builds its own shorter list insideCardTitleEditorinapps/canvas/src/cards/CardBodyEditor.tsx, which isStarterKitplusMention; a node the title should hold has to be added there too. - Add the node name to
RICH_TEXT_NODE_NAMESand its attributes toRICH_TEXT_NODE_ATTRIBUTE_NAMESinpackages/graph/src/publish/validation/richTextSchema.ts. This is the step people forget:apps/canvas/src/editor/editorSchemaContract.test.tscompares the two lists against the real schema, so a node missing from the allowlist fails there rather than in a share. - Write a test beside the node. Build an
EditorwithStarterKitand the node alone, then assert serialisation, attribute round-trip and the view.apps/canvas/src/editor/imageLinkNode.test.tsxandapps/canvas/src/editor/attachmentNode.test.tsxare the two shapes to copy.
A node that reads the workspace needs the id it reads from. TourStep.configure({ cardId }) is the pattern: the extension takes the owning card as an option, and the node view reads the store rather than receiving a copy through props.
Add a mark
Section titled “Add a mark”- Define the mark in
apps/canvas/src/editor/, or take it fromStarterKitif Tiptap already ships it. The body currently acceptsbold,italic,strike,code,linkandunderline. - Register it in
cardBodySchemaExtensions. - Add the mark name to
RICH_TEXT_MARK_NAMESinpackages/graph/src/publish/validation/richTextSchema.ts. - Add a control to the
BubbleMenuinapps/canvas/src/cards/CardBodyEditor.tsx, and add its pressed state to theuseEditorStateselector beside the existing ones. The selector is what keeps a keystroke from re-rendering the whole editor. - Extend
editorSchemaContract.test.tsif the mark adds attributes, and add a case to the export tests if the mark changes serialised output.
A mark does not need a Loro change: the CRDT stores the ProseMirror tree, so a new mark is a schema change only.
Add a field renderer
Section titled “Add a field renderer”A built-in field kind is rendered by the switch in FieldValueInput in apps/canvas/src/cards/PropertyField.tsx. A kind that needs more than a native control registers an editor instead, which is how rating, group and list ship without touching the switch.
- If the kind is new to the schema, add it to
fieldKindsinpackages/graph/src/schema.ts, extendreadPropertyValueinpackages/graph/src/property.tsso it has a textual reading, and add the parse and reject cases topackages/graph/src/schema.test.ts. That work is described in add-a-feature.md. - Write the editor component in
apps/canvas/src/field/. It receivesFieldEditorPropsfromapps/canvas/src/field/fieldKindRegistry.tsx: the field, the value,onCommit(withnullmeaning clear),autoFocus,onDoneandonCancel. - Register it with
registerFieldKind({ kind, editor })at module scope.apps/canvas/src/field/compositeKinds.tsxis the example, andapps/canvas/src/cards/PropertyField.tsximports it so the registration happens before any control renders; a new module needs an import from a component that is always mounted, or the kind registers nowhere. - Do not add a formatter. Read-only surfaces format through
formatPropertyValueinpackages/graph/src/property.ts; the registry has noformatfield on purpose, because two formatters for one value drifted once already. - Test the editor with React Testing Library: commit on change, commit
nullwhen cleared, and thearia-labela screen reader hears.apps/canvas/src/field/compositeKinds.test.tsxcovers the three composing kinds.
If the control is shared by the inspector and an inline block, keep the parse rules in PropertyField.tsx so both surfaces commit the same value.
Add a command
Section titled “Add a command”- Add the row to
apps/canvas/src/command/commandRegistry.ts: id, group, the surfaces that list it, the capabilities it requires and the state predicate that decides whether it is available. Availability is one rule for every surface, so a read-only session loses the command everywhere at once. - Add the handler to the context type beside it. A command calls exactly one handler, and a surface adopts the command by supplying the handler it already owns.
- If the row needs a target list (boards, cards, views, sections, types), extend
CommandContextand the provider inapps/canvas/src/command/paletteProviders.tsrather than filtering in the palette component. - Update
apps/canvas/src/command/commandRegistry.test.ts, which checks availability table-wise. A command with no test row is a command nobody has proved. - If the command should also appear in the
/menu, add it toSLASH_ITEMSinapps/canvas/src/editor/SlashCommands.tsinstead: those rows run against the editor and consume the typed query first.
A palette row shows a title and an optional subtitle. Keep the title the verb, and put the target in the subtitle.
Add a slash command
Section titled “Add a slash command”- Append to
SLASH_ITEMSinapps/canvas/src/editor/SlashCommands.ts:id,title, thehintline, aglyph,keywords, andrun(editor, range). StartrunwithconsumeQuery(editor, range)so the typed/querynever becomes text. - Add keywords for the names people actually type; the query matches them as well as the title.
- Cover it in
apps/canvas/src/editor/SlashCommands.test.ts: the row exists, and running it produces the expected document. - If the row needs input from the reader, insert a widget rather than opening a dialog.
ImageUrlPromptis the example: the caret and the editing session survive the answer.
Add a mention action
Section titled “Add a mention action”A mention row is not a slash row. Rows come from apps/canvas/src/editor/SuggestionList.tsx, and the three kinds are fixed by what the chip can store: a card, a field of the current card (a property block), and a field of another card (a @card.field chip).
- Extend the row builder in
SuggestionList.tsxand keepMentionSuggestionItem.kindaccurate; the insert path branches on it. - If the row inserts a node, add that node to the body schema and the publish allowlist as described above.
- Keep the picker’s keyboard contract:
ArrowDownandArrowUpmove,ArrowRightor.enters a card’s fields,ArrowLeftleaves them,Enterinserts,Escapecloses.apps/canvas/src/editor/Mention.test.tsandapps/canvas/src/editor/SuggestionList.test.tscover the picker;apps/canvas/src/editor/MentionChip.test.tsxcovers what the chip renders.
Verify
Section titled “Verify”pnpm exec vitest run apps/canvas/src/editor apps/canvas/src/cards apps/canvas/src/fieldpnpm --filter @suite/canvas typecheckpnpm exec vitest run packages/graph/srcThen run the local gate from test-and-verify.md before you call it done. E2E runs
on request; pnpm e2e:smoke is the narrowest run that covers the editor.
Storybook is worth a look when the change is visible: pnpm storybook, then the Components/ group. A new node that renders an image, a chip or a block belongs there.
A schema change also needs the publish allowlist, the export tests and the share projection reviewed, because all three read the same node list. If the change alters what a shared body may contain, update packages/graph/src/publish/validation/richTextSchema.ts in the same commit.