RkfEdit — the edit API
RkfEdit is the headless edit API of @wity-ai/rkf-core: the operations on an RKF document, pure over the document model (the object parse() returns and serialize() takes).
import { RkfEdit, splitBlocksByHeading } from '@wity-ai/rkf-core';Every function changes the document (or card, or machine) it is given in place and returns what a caller needs to record or report — the created element, the removed element, the previous position. Nothing here knows about undo history, events, selection, rendering or playback; those belong to the caller.
Addressing
| Thing | Addressed by |
|---|---|
| Section | id |
| Card or other child of a section | index within its section, or its stable _uid (findChildIndex) |
| Link between cards (connector) | _uid |
| Block inside a card | id — stable, unique within its card |
| Drawing mark | id — stable, unique in the document |
| Machine | id — unique among the document's machines |
| Machine var / gate / derive | id — its name in expressions, unique among the three |
| Machine transform / port | a generated id |
normalizeDoc(doc) brings a loaded document to this shape: every section gets a viewport; every child a _uid and a pos; legacy single-content cards a blocks array; every block, mark, transform and port an id; rows the canonical cell shape. It is idempotent and never changes content. An embed's or a machine's existing id is never changed.
Document and sections
createDoc() → doc // one 'main' section
normalizeDoc(doc) → doc
findSection(doc, sectionId) → section | null
addSection(doc, label = 'New Section') → section
renameSection(doc, sectionId, label) → section | null // throws on an empty name
removeSection(doc, sectionId) → section | null
setDecisionSpace(doc, xml) → doc // an opaque <wity-knowledge> string; a falsy value clears itCards and other children
getChild(doc, sectionId, childIndex) → child | null
findChildIndex(doc, sectionId, childUid) → index | -1
nextBlockPos(doc, sectionId) → { x, y, w } // below the lowest existing card
addCard(doc, sectionId, { content?, blocks?, pos? }) → { child, childIndex } | null
addMachine(doc, sectionId) → { child, childIndex } | null
insertChild(doc, sectionId, childIndex, child) → boolean
removeChild(doc, sectionId, childIndex) → { child, connectors } | null // links to/from it are removed too
moveChild(doc, sectionId, childIndex, { x, y }) → previous pos | null | undefined (not found)
resizeChild(doc, sectionId, childIndex, width) → { before, after } | null
setChildBlocks(doc, sectionId, childIndex, blocks) → previous blocks | null
setChildContent(doc, sectionId, childIndex, content) → boolean // the legacy single `content` field
splitCard(doc, sectionId, childUid) → { removed: { child, childIndex, connectors }, added: [{ child, childIndex }] } | nulladdCard takes blocks (the card's block list) or content (one text block). A card's width is at least MIN_BLOCK_WIDTH (120); the default is DEFAULT_BLOCK_WIDTH (320).
splitCard groups a card's blocks by heading (splitBlocksByHeading): at the shallowest heading level that occurs at least twice, with content before the first such heading folded into the first group. It returns null when there is nothing to split on.
Card style
CARD_STYLE_KEYS = ['borderColor', 'borderWidth', 'backgroundColor']
checkCardStyle(attrs) → attrs // throws on an unknown key or a wrong value
setChildStyle(doc, sectionId, childUid, attrs) → child | nullColours are hex (#3b82f6); borderWidth is a number from 1 to 4. null or '' clears a key; a card whose style becomes empty has no style.
Links between cards
getConnectors(doc, sectionId) → connector[]
addConnector(doc, sectionId, { from, fromPort, to, toPort, color?, width?, data? }) → connector | null
updateConnector(doc, sectionId, connectorUid, attrs) → connector | null
removeConnector(doc, sectionId, connectorUid) → connector | nullfrom / to are card _uids. data is a free-form property bag.
Blocks inside a card
These take the container whose blocks list is edited — a card, or a row cell.
newBlock(kind, opts?) → block // opts: heading, list (text) · application (embed) · columns (row)
findBlock(container, blockId) → { list, index, block, row, cellIndex } | null // looks inside rows
insertBlock(container, block, where?) → { block, index }
updateBlock(container, blockId, patch) → { block, before }
removeBlock(container, blockId) → { block, index, row, cellIndex }
moveBlock(container, blockId, where?) → { block, index }
splitTextBlock(container, blockId, { before, after }) → { block, created }
mergeIntoPrevious(container, blockId) → { into, removed, offset }
setBlocks(container, blocks) → previous list
walkBlocks(container) → iterator of { block, row, cellIndex }
normalizeBlocks(blocks) → blockswhere is one of { index }, { afterId }, { beforeId }, { rowId, cell, index? }; omitted means the end.
insertBlockcopies the block, gives it an id if it has none (or a fresh one if its id is already used in the container).updateBlock:undefinedornullin the patch removes a field.kind,id,cellsandblockscannot be changed by an update.splitTextBlockon an empty list item ends the list instead of adding a block.- These throw when the block (or row) does not exist.
Block kinds (BLOCK_KINDS): text, image, math, embed, machine, mermaid, row.
Rows
A row is a container:
{ kind: 'row', id, columns, cells: [ { blocks: [ … ] }, … ] }Each cell holds its own ordered blocks. A cell cannot hold a row, embed or machine block, and is never left empty — removing its last block leaves an empty text block. The earlier shape, { kind: 'row', columns, blocks: [ … ] } (one block per cell), is read and upgraded without losing anything (normalizeRow).
setRowColumns(container, rowId, columns) → { row, columns, cells }
removeRowCell(container, rowId, cellIndex) → { row, cell, columns }
normalizeRow(row) → row
MAX_ROW_COLUMNS = 8setRowColumns: growing pads empty cells; shrinking drops only trailing empty cells — content is never discarded. removeRowCell removes a cell with its content; a row keeps at least one cell.
Drawing marks
The shapes drawn on a section's canvas (its rkf-canvas child). Coordinates are in the section's canvas space, as card positions are.
| Shape | Required | Optional |
|---|---|---|
line | x1, y1, x2, y2 | color, width, dash |
arrow | x1, y1, x2, y2 | color, width |
rect | x, y, width, height | stroke, strokeWidth, fill, rx |
circle | cx, cy, r | stroke, strokeWidth, fill |
path | d (SVG path data) | stroke, strokeWidth, fill |
text | content, x, y | color, fontSize, fontWeight, anchor |
Every shape also takes opacity (0–1). Colours are hex; fill may be 'none'.
MARK_SHAPES // shape → { tag, required, optional }
newMark(shape, attrs) → mark // with an id; throws on a missing or wrong attribute
markShape(mark) → shape | null
findMark(doc, markId) → { section, index, mark } | null
setMarkAttrs(doc, markId, attrs) → { section, index, mark } | null // null or '' removes an optional attribute
normalizeMarks(doc) → doc
getDrawingCanvas(doc, sectionId) → canvas | null // created on first use
getMarks(doc, sectionId) → mark[]
addMark(doc, sectionId, mark) → boolean
updateMark(doc, sectionId, markIndex, attrs) → mark | null
removeMark(doc, sectionId, markIndex) → mark | nullMachines
A machine is a block inside a card (kind: 'machine') — always a top-level block of its card.
evaluate() and RkfPlayer run the machines that are children of a section. They do not look inside cards, so a caller that wants machine blocks to run places them among the section's children in the document it evaluates (leaving the stored document as it is).
| Part | Shape |
|---|---|
var | { id, type, init, elasticity?, elasticDefault? } — type: number | boolean | string |
gate | { id, expr } |
derive | { id, expr } |
transform | { id, trigger, on?, sets: [{ var, expr, when? }] } |
port | { id, dir, var, label?, type?, … } |
listMachines(doc) → [{ section, child, machine }]
findMachine(doc, machineId) → { section, child, machine } | null
renameMachine(doc, machineId, newId) → { section, child, machine } | null
findMachinePart(machine, part, id) → { index, item } | null
setMachinePart(machine, part, { id, newId?, ...fields }) → { item, index, created, unknownRefs }
removeMachinePart(machine, part, id) → { item, usedBy } | null
normalizeMachine(machine) → machinesetMachinePart adds a part or changes one; only the fields given change. For a var / gate / derive, id is its name — a new name adds one, and newId renames it. For a transform / port, id is the generated id of an existing one — leave it out to add one. Nothing changes when a rule is broken; the error says which:
- names are identifiers (letters, digits,
_; nottrue,falseorMath), and unique among a machine's vars, gates and derives; - expressions must parse;
- derives and gates must not depend on each other in a circle;
- an input port's settings must fit its type —
continuous:min,max,step·choice:options,labels·boolean·clock:interval·increment:by. An output port takes onlyvarandlabel.
An expression may refer to names outside its machine, so an unknown name is not refused: it is reported back as unknownRefs. removeMachinePart leaves whatever still refers to a removed var / gate / derive as it is, and lists it in usedBy.
Example
import { RkfEdit, serialize } from '@wity-ai/rkf-core';
const doc = RkfEdit.createDoc();
const { child: card } = RkfEdit.addCard(doc, 'main', { content: 'Hello' });
const { block: row } = RkfEdit.insertBlock(card, RkfEdit.newBlock('row', { columns: 2 }));
RkfEdit.insertBlock(card, RkfEdit.newBlock('mermaid'), { afterId: row.id });
const { block: machine } = RkfEdit.insertBlock(card, RkfEdit.newBlock('machine'));
RkfEdit.setMachinePart(machine, 'var', { id: 'x', init: 2 });
RkfEdit.setMachinePart(machine, 'derive', { id: 'y', expr: 'x * x' });
RkfEdit.addMark(doc, 'main', RkfEdit.newMark('rect', { x: 0, y: 0, width: 10, height: 5 }));
RkfEdit.setChildStyle(doc, 'main', card._uid, RkfEdit.checkCardStyle({ borderColor: '#3b82f6', borderWidth: 2 }));
const xml = serialize(doc); // requires the graph module — see rkf-core Setup