Visual Editing
HTML Annotations for Visual Editing
Add data attributes to your rendered HTML to enable progressively richer visual editing:
- `data-block-uid="blockId"` — Click-to-select blocks. Hydra.js adds click handlers and shows a blue outline and Quanta toolbar on selected blocks.
- `data-edit-text="fieldName"` — Inline text editing. For simple text, click and type directly. For rich text (slate widget), select text to apply formatting via the Quanta toolbar.
- `data-edit-media="fieldName"` — Visual media uploading. Editors can upload, pick or drag-and-drop images directly onto the element.
- `data-edit-link="fieldName"` — Link editing. Click behaviour is replaced with a link picker to select content, enter an external URL, or open the link.
Example of a fully annotated slide block:
<div class="slide" data-block-uid="slide-1">
<img data-edit-media="image" src="/big_news.jpg"/>
<h2 data-edit-text="title">Big News</h2>
<div data-edit-text="description">
Check out <b>hydra</b>, it will change everything
</div>
<a data-edit-link="url"
data-edit-text="buttonText"
href="/big_news">Read more</a>
</div>Comment Syntax
If you can't modify the markup (e.g., using a 3rd party component library), use comment syntax to specify block attributes:
<!-- hydra block-uid=block-123
edit-text=title(.card-title)
edit-media=url(img)
edit-link=href(a.link) -->
<div class="third-party-card">
<h3 class="card-title">Title</h3>
<img src="image.jpg">
<a class="link" href="...">Read more</a>
</div>
<!-- /hydra -->- Attributes without selectors apply to the root element:
block-uid=xxx - Attributes with selectors target child elements:
edit-text=title(.card-title) - Closing
<!-- /hydra -->marks end of scope - Self-closing
<!-- hydra block-uid=xxx /-->applies only to next sibling element
Supported attributes: block-uid, block-readonly, edit-text, edit-link, edit-media, block-add
Allowed Navigation (data-linkable-allow)
Add data-linkable-allow to elements that should navigate during edit mode (paging links, facet controls, etc.):
<a href="/page?pg=2" data-linkable-allow>Next</a>
<select data-linkable-allow @change="handleFilter">...</select>Path Syntax for Parent/Page Fields
The data-edit-text|edit-media|edit-link attributes support Unix-style paths to edit fields outside the current block:
- `fieldName` — edit the block's own field (default)
- `../fieldName` — edit the parent block's field
- `../../fieldName` — edit the grandparent's field
- `/fieldName` — edit the page metadata field
<!-- Edit the page title (not inside any block) -->
<h1 data-edit-text="/title">My Page Title</h1>
<!-- Edit the page description -->
<p data-edit-text="/description">Page description here</p>
<!-- Inside a nested block, edit the parent container's title -->
<h3 data-edit-text="../title">Column Title</h3>This allows fixed parts of the page (like headers) to be editable without being inside a block.
Readonly Regions
Add data-block-readonly (or <!-- hydra block-readonly --> comment) to disable inline editing for all fields inside an element:
<div class="teaser" data-block-uid="teaser-1">
<div data-block-readonly>
<h2 data-edit-text="title">Target Page Title</h2>
</div>
<a data-edit-link="href" href="/target">Read more</a>
</div>Or using comment syntax:
<!-- hydra block-readonly -->
<div class="listing-item" data-block-uid="item-1">...</div>Renderer Node-ID Rules
When rendering Slate nodes to DOM, your renderer must follow these rules for data-node-id:
- Element nodes (
p,strong,em, etc.) must havedata-node-idmatching the Slate node'snodeId - Wrapper elements — If you add extra wrapper elements around a Slate node, ALL wrappers must have the same
data-node-idas the inner element
hydra.js uses node-ids to map between Slate's data model and your DOM. When restoring cursor position after formatting changes, it walks your DOM counting Slate children.
Valid wrapper pattern:
<strong data-node-id="0.1"><b data-node-id="0.1">bold</b></strong>
Both elements have the same node-id, so they count as one Slate child.
Invalid (missing node-id on wrapper):
<span class="my-style"><strong data-node-id="0.1">bold</strong></span>
This breaks cursor positioning because hydra.js can't correlate DOM structure to Slate structure.One top-level node per slate field
A slate field's value is an array, but it always holds exactly one top-level node — a single paragraph, heading, list, or blockquote. Inline content (bold, links, …) lives in that node's children.
Editing can transiently produce more than one top-level node — pasting multiple paragraphs, pressing Enter, or a Backspace that demotes a list item to a paragraph ([ul, p]). Hydra normalizes that immediately:
- Split — when the field is the
valueof aslateblock, each extra
node becomes its own slate block, inserted after the original in the same container (blocks_layout or object_list). This is how pressing Enter in a text block produces a new block.
- Flatten — when the field can't be split — a slate field of a
non-slate block (e.g. a slateTable cell's value), or a container that's full or in table mode — the extra nodes' content merges back into the first node. No text is lost.
A frontend renderer can therefore always assume one top-level node per slate field; it never has to handle a multi-node value.
Complete Slate Rendering Example
Slate data structure (value is an array but always contains a single root node):
{
"value": [
{
"type": "p", "nodeId": "0",
"children": [
{ "text": "Hello " },
{ "type": "strong", "nodeId": "0.1",
"children": [{ "text": "world" }] },
{ "text": "! Visit " },
{ "type": "link", "nodeId": "0.3",
"data": { "url": "/about" },
"children": [{ "text": "our page" }] }
]
}
]
}Renderer:
function renderSlate(nodes) {
return (nodes || []).map(node => {
if (node.text !== undefined) return escapeHtml(node.text);
const tag = { p:'p', h1:'h1', h2:'h2', strong:'strong',
em:'em', link:'a' }[node.type] || 'span';
const attrs = node.type === 'link'
? ` href="${node.data?.url || '#'}"` : '';
return `<${tag} data-node-id="${node.nodeId}"${attrs}>${renderSlate(node.children)}</${tag}>`;
}).join('');
}Usage:
<div data-block-uid="block-1" data-edit-text="value">
<!-- renderSlate(block.value) output goes here -->
</div>