Rich-text editor
tier 3 — a surface
A document a reader writes with formatting — a note, a description, a reply — posted as
HTML. Your markup is the frame, the toolbar, the link and media forms and a <textarea> that
holds the value; the library ships the engine and loads it the first time a page shows an editor.
An editor
Live — type, format, add a link with Ctrl + K.
[data-editor] on the .editor frame makes it one. The document is edited in
[data-editor-body]; put .prose on it so what is typed looks like what is later
shown. textarea[data-editor-value] is the field: the editor loads from it and writes every
change back to it, as HTML, so a form posts it and a Blazor binding reads it. Keep it
hidden. The editable element takes the textarea's label, its placeholder and its state.
A button with data-editor-format toggles its format on the selection and shows, with
aria-pressed, what the text under the caret has. A button pressed with the pointer leaves
the focus and the selection in the document.
<div class="form-field">
<label class="form-label" for="note-value">Handover note</label>
<div class="editor" data-editor id="note">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold" data-tip="Bold · Ctrl+B"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="italic" aria-label="Italic" data-tip="Italic · Ctrl+I"><i class="ri-italic"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="underline" aria-label="Underline" data-tip="Underline · Ctrl+U"><i class="ri-underline"></i></button>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="bullet" aria-label="Bulleted list" data-tip="Bulleted list"><i class="ri-list-unordered"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="ordered" aria-label="Numbered list" data-tip="Numbered list"><i class="ri-list-ordered"></i></button>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="link" aria-label="Link" data-tip="Link · Ctrl+K"><i class="ri-link"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<div class="editor-link" data-editor-link hidden>
<input class="form-input" type="url" data-editor-link-url aria-label="Address" placeholder="https://" />
<button class="btn btn--sm" type="button" data-editor-link-save>Save</button>
<button class="btn btn--ghost btn--sm" type="button" data-editor-link-remove>Remove</button>
</div>
<textarea id="note-value" name="note" data-editor-value hidden placeholder="What the next shift needs to know"><p><strong>Three of five lines are reserved</strong> in the west warehouse.</p><ul><li>Hold the other two for stock, checked every hour.</li><li>Alex Fischer owns the reservation until Monday.</li></ul></textarea>
</div>
</div>
Every control
The formats an editor accepts are the ones its toolbar offers. Paste a page in here:
what it had beyond these is cut away, so the document never holds what the reader could not have
written. A format with a value sets that value — a heading level, a kind of list,
an alignment — and a <select> sets the option chosen, with
"" for a paragraph. data-editor-action buttons undo, redo, indent,
outdent and clear the formatting; undo and redo are disabled while there is nothing to do.
The toolbar is one tab stop: ← →, Home and End move between its buttons, Alt + F10 in the document goes to it and Esc comes back. Tab in the document leaves it, as from a textarea — except in a list or a code block, where it indents.
<div class="editor" data-editor id="article" style="--editor-max-height: 22rem">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-action="undo" aria-label="Undo" data-tip="Undo · Ctrl+Z"><i class="ri-arrow-go-back-line"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-action="redo" aria-label="Redo" data-tip="Redo · Ctrl+Y"><i class="ri-arrow-go-forward-line"></i></button>
<span class="editor-sep" role="separator"></span>
<select class="form-select form-select--sm" data-editor-format="header" aria-label="Text style">
<option value="">Paragraph</option>
<option value="2">Heading</option>
<option value="3">Subheading</option>
</select>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold" data-tip="Bold · Ctrl+B"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="italic" aria-label="Italic" data-tip="Italic · Ctrl+I"><i class="ri-italic"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="underline" aria-label="Underline" data-tip="Underline · Ctrl+U"><i class="ri-underline"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="strike" aria-label="Strikethrough" data-tip="Strikethrough"><i class="ri-strikethrough"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="code" aria-label="Code" data-tip="Code"><i class="ri-code-line"></i></button>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="bullet" aria-label="Bulleted list" data-tip="Bulleted list"><i class="ri-list-unordered"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="ordered" aria-label="Numbered list" data-tip="Numbered list"><i class="ri-list-ordered"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="check" aria-label="Checklist" data-tip="Checklist"><i class="ri-list-check-3"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-action="outdent" aria-label="Outdent" data-tip="Outdent · Shift+Tab"><i class="ri-indent-decrease"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-action="indent" aria-label="Indent" data-tip="Indent · Tab"><i class="ri-indent-increase"></i></button>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="blockquote" aria-label="Quote" data-tip="Quote"><i class="ri-double-quotes-l"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="code-block" aria-label="Code block" data-tip="Code block"><i class="ri-code-box-line"></i></button>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="align" aria-label="Align to the start" data-tip="Align to the start"><i class="ri-align-left"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="align" value="center" aria-label="Centre" data-tip="Centre"><i class="ri-align-center"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="align" value="right" aria-label="Align to the end" data-tip="Align to the end"><i class="ri-align-right"></i></button>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="link" aria-label="Link" data-tip="Link · Ctrl+K"><i class="ri-link"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-action="clean" aria-label="Clear formatting" data-tip="Clear formatting"><i class="ri-format-clear"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<div class="editor-link" data-editor-link hidden>
<input class="form-input" type="url" data-editor-link-url aria-label="Address" placeholder="https://" />
<button class="btn btn--sm" type="button" data-editor-link-save>Save</button>
<button class="btn btn--ghost btn--sm" type="button" data-editor-link-remove>Remove</button>
<button class="btn btn--ghost btn--sm" type="button" data-editor-link-cancel>Cancel</button>
</div>
<textarea name="article" data-editor-value hidden aria-label="Release note"><h2>Faster checkout</h2><p>The payment step now keeps the basket while a card is <em>confirmed</em>, so a slow bank no longer empties it. See the <a href="/help/checkout">checkout guide</a>.</p><ul><li>Saved cards load before the form.</li><li>An expired card says so at once.</li></ul><blockquote>A retry keeps the reader where they were.</blockquote><pre>checkout.retry = "keep-basket"</pre></textarea>
</div>
A short one
Four buttons, four formats: a comment cannot be given a heading or a list, however it is pasted in.
--editor-max-height on the frame caps how tall the document grows before it scrolls, and
the textarea's placeholder is what an empty one says.
<div class="editor" data-editor id="comment" style="--editor-max-height: 10rem">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold" data-tip="Bold · Ctrl+B"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="italic" aria-label="Italic" data-tip="Italic · Ctrl+I"><i class="ri-italic"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="code" aria-label="Code" data-tip="Code"><i class="ri-code-line"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="link" aria-label="Link" data-tip="Link · Ctrl+K"><i class="ri-link"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<div class="editor-link" data-editor-link hidden>
<input class="form-input" type="url" data-editor-link-url aria-label="Address" placeholder="https://" />
<button class="btn btn--sm" type="button" data-editor-link-save>Save</button>
<button class="btn btn--ghost btn--sm" type="button" data-editor-link-remove>Remove</button>
</div>
<textarea name="comment" data-editor-value hidden aria-label="Comment" placeholder="Add a comment"></textarea>
</div>
Read-only
readonly or disabled on the textarea stops the editing, dims the toolbar and
marks the document aria-readonly. The editor follows the attribute when it changes, so a
Blazor binding to disabled needs no call.
<div class="editor" data-editor id="signed-off">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="italic" aria-label="Italic"><i class="ri-italic"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="bullet" aria-label="Bulleted list"><i class="ri-list-unordered"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<textarea name="summary" data-editor-value hidden readonly aria-label="Signed-off summary"><p>Signed off by <strong>Alex Fischer</strong> on Monday. The summary can no longer be changed.</p><ul><li>Two lines shipped.</li><li>Three lines back-ordered.</li></ul></textarea>
</div>
Images and videos
Live — put the caret on a line and use the image or video button.
data-editor-format="image" and "video" open your own form,
.editor-media[data-editor-media], written once inside the editor and placed at the caret
like the link form: [data-editor-media-url] takes the address,
[data-editor-media-alt] the description — an image's alt, a video's
aria-label — and [data-editor-media-save] inserts. The form says which it
is for in data-editor-media-kind, and shows an element marked
data-editor-media-for="image" or "video" only for that kind. Opened on an image
or a video, it is filled with it, and Insert replaces it.
An image sits in its line as <img src alt>; a video is a block of its own,
<video controls src>, which never plays by itself. Both are formats like any other: an
editor whose toolbar offers neither cuts them out of what is pasted. Only http,
https and relative addresses are kept — a data: image is dropped, and a
file pasted or dropped into the document is not inlined. Uploading is the app's: put the
file where it can be fetched, and hand the editor its address.
<div class="editor" data-editor id="manual-page" style="--editor-max-height: 28rem">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold" data-tip="Bold · Ctrl+B"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="italic" aria-label="Italic" data-tip="Italic · Ctrl+I"><i class="ri-italic"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="header" value="2" aria-label="Heading" data-tip="Heading"><i class="ri-h-2"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="ordered" aria-label="Numbered list" data-tip="Numbered list"><i class="ri-list-ordered"></i></button>
<span class="editor-sep" role="separator"></span>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="link" aria-label="Link" data-tip="Link · Ctrl+K"><i class="ri-link"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="image" aria-label="Image" data-tip="Image"><i class="ri-image-line"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="video" aria-label="Video" data-tip="Video"><i class="ri-video-line"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<div class="editor-link" data-editor-link hidden>
<input class="form-input" type="url" data-editor-link-url aria-label="Address" placeholder="https://" />
<button class="btn btn--sm" type="button" data-editor-link-save>Save</button>
<button class="btn btn--ghost btn--sm" type="button" data-editor-link-remove>Remove</button>
</div>
<div class="editor-media" data-editor-media hidden>
<input class="form-input" type="url" data-editor-media-url aria-label="Address of the image or video" placeholder="https://" />
<input class="form-input" type="text" data-editor-media-alt data-editor-media-for="image" aria-label="What the image shows" placeholder="What it shows" />
<input class="form-input" type="text" data-editor-media-alt data-editor-media-for="video" aria-label="What the video is about" placeholder="What it is about" />
<button class="btn btn--sm" type="button" data-editor-media-save>Insert</button>
<button class="btn btn--ghost btn--sm" type="button" data-editor-media-cancel>Cancel</button>
</div>
<textarea name="manual-page" data-editor-value hidden aria-label="Manual page"><h2>Receiving a pallet</h2><p>Check the racking label before the pallet leaves the dock:</p><p><img src="https://images.unsplash.com/photo-1587293852726-70cdb56c2866?w=960&h=540&fit=crop&q=70&auto=format" alt="A warehouse aisle of tall pallet racking" width="480"></p><p><em>Bay 4 after the racking was moved. Photo by CHUTTERSNAP on Unsplash.</em></p><ol><li>Scan the pallet.</li><li>Put it in the bay the scanner names.</li></ol></textarea>
</div>
Your own picker for images
Live — put the caret in the report, then add a photo. Before the media form opens,
the editor sends sedna-editor-media with { kind, src, alt }. Cancel it with
@onsedna-editor-media:preventDefault and the form stays shut: open your own dialog —
an upload, a media library — and answer with
ISednaEditors.InsertImageAsync(id, url, alt) or InsertVideoAsync(id, url, label),
which put the media where the caret was and answer null when the address is not one the editor keeps.
From script, it is insertImage(url, alt) and insertVideo(url, label) on the
editor's handle.
@inject ISednaEditors Editors
@inject ISednaUi Ui
@* The app answers the image control itself: cancelling sedna-editor-media keeps the editor's
own form shut, and the app's dialog — an upload, a media library — supplies the address. *@
<div class="editor" data-editor id="incident-note"
@onsedna-editor-media="PickImage" @onsedna-editor-media:preventDefault>
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="bullet" aria-label="Bulleted list"><i class="ri-list-unordered"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="image" aria-label="Add a photo" data-tip="Add a photo"><i class="ri-image-add-line"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<textarea data-editor-value hidden aria-label="Damage report" @bind="_report"></textarea>
</div>
@* The app's own picker. Each row is a dialog-method submitter, so the photo it names becomes
the dialog's returnValue. *@
<dialog id="pick-photo" class="modal modal--sm" aria-labelledby="pick-photo-title">
<form method="dialog">
<div class="modal-header">
<h3 id="pick-photo-title">Add a photo</h3>
</div>
<div class="modal-body">
<ul class="list">
@foreach (var photo in _photos)
{
<li>
<button class="list-row" value="@photo.Id">
<span class="list-main">
<span class="list-title">@photo.Alt</span>
<span class="list-sub">@photo.Credit</span>
</span>
</button>
</li>
}
</ul>
</div>
<div class="modal-footer">
<button class="btn" value="">Cancel</button>
</div>
</form>
</dialog>
@code {
private readonly (string Id, string Url, string Alt, string Credit)[] _photos =
[
("racking", "https://images.unsplash.com/photo-1587293852726-70cdb56c2866?w=960&h=540&fit=crop&q=70&auto=format",
"The racking in bay 4", "Photo by CHUTTERSNAP on Unsplash"),
("crates", "https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=960&h=540&fit=crop&q=70&auto=format",
"The crates on the floor", "Photo by Adrian Sulyok on Unsplash"),
];
private string _report = "<p>Two crates arrived crushed at dock 4.</p>";
// The caret is remembered while the dialog has the focus, so the photo lands where the
// reader left off.
private async Task PickImage(SednaEditorMediaEventArgs e)
{
var id = await Ui.ShowModalAsync("pick-photo");
var photo = _photos.FirstOrDefault(p => p.Id == id);
if (photo.Url is null) return;
await Editors.InsertImageAsync("incident-note", photo.Url, $"{photo.Alt}. {photo.Credit}.");
}
}
The link form
Your own form, written once inside the editor as .editor-link[data-editor-link], which the
script opens beneath the text it is for — from a data-editor-format="link" button or
Ctrl + K. It is filled with the address of the link under
the caret, and [data-editor-link-remove] shows only then. Enter saves,
Esc closes, and [data-editor-link-cancel] closes too. With no text
selected, the address is written as the link's text. Only http, https,
mailto, tel and sms addresses are kept.
In a form
The textarea is the field, so a form works as it would with a textarea: it posts the document under the
textarea's name, a reset puts it back, and without the script the textarea shows in the frame
instead, still posting. required is held by the editor, because a browser refuses to submit
over a hidden field it cannot focus: an empty one stops the submit, is marked aria-invalid
and takes the focus. The HTML is the editor's own, cut down to the formats it accepts — a
convenience, not a sanitiser: what arrives at the server is still the server's to check.
<form method="post" action="/notes">
<div class="form-field">
<label class="form-label" for="summary-value">Summary</label>
<div class="editor" data-editor id="summary">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="bullet" aria-label="Bulleted list"><i class="ri-list-unordered"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<!-- The field: posted as "summary", required, and shown in the frame when there is no script. -->
<textarea id="summary-value" name="summary" data-editor-value hidden required
aria-describedby="summary-hint"></textarea>
</div>
<span class="form-hint" id="summary-hint">What changed, in a sentence or two.</span>
</div>
<button class="btn btn--go btn--solid" type="submit">Save</button>
<button class="btn btn--ghost" type="reset">Start again</button>
</form>
In Blazor
Live — type, then use the template. @bind on the textarea is the whole
binding: the editor sends the textarea's input at each change and change when
it loses the focus after one, so @bind and @bind:event="oninput" both work,
and a value set in C# is loaded into the document. @onsedna-editor-change adds the text,
its length and whether it is empty, for a counter or a send button that waits for something to send.
ISednaEditors, registered by AddSednaUi(), is for what a binding cannot do —
type at the reader's caret, focus the document, switch it off — by the editor element's id. None of
it runs while the page prerenders: call it from a handler, or after the first render.
<p>Thanks for your order. It leaves the warehouse on <strong>Tuesday</strong>.</p>@inject ISednaEditors Editors
<div class="sedna-stack">
<div class="editor" data-editor id="reply" @onsedna-editor-change="Changed" @onsedna-editor-ready="Changed">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="italic" aria-label="Italic"><i class="ri-italic"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="bullet" aria-label="Bulleted list"><i class="ri-list-unordered"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
@* The value is the textarea's, bound like any other field. *@
<textarea data-editor-value hidden aria-label="Reply to the customer" placeholder="Write a reply"
@bind="_reply" @bind:event="oninput"></textarea>
</div>
<div class="toolbar">
<div class="toolbar-filters">
<button class="btn btn--sm" type="button" @onclick="Sign"><i class="ri-quill-pen-line"></i> <span>Sign it</span></button>
<button class="btn btn--sm" type="button" @onclick="UseTemplate"><i class="ri-file-text-line"></i> <span>Use the template</span></button>
<button class="btn btn--ghost btn--sm" type="button" @onclick="Clear">Clear</button>
</div>
<span class="toolbar-count">@_length characters</span>
<button class="btn btn--go btn--solid btn--sm" type="button" disabled="@_empty">Send</button>
</div>
@* What the field holds — what a form would post and the component would save. *@
<div class="code-block">
<div class="code-block-head"><span>The bound value</span></div>
<pre><code>@(string.IsNullOrEmpty(_reply) ? "(empty)" : _reply)</code></pre>
</div>
</div>
@code {
private string _reply = "<p>Thanks for your order. It leaves the warehouse on <strong>Tuesday</strong>.</p>";
private int _length;
private bool _empty = true;
private void Changed(SednaEditorEventArgs e)
{
_length = e.Length;
_empty = e.IsEmpty;
}
// A change made in C# is a change to the field: the editor follows the binding.
private void UseTemplate() =>
_reply = "<p>Thanks for getting in touch.</p><ul><li>What happened</li><li>What we will do</li></ul>";
// What a binding cannot do — type at the reader's caret — is one call.
private async Task Sign() => await Editors.InsertTextAsync("reply", " — Alex Fischer, Northwind Retail");
private async Task Clear()
{
await Editors.SetHtmlAsync("reply", string.Empty);
await Editors.FocusAsync("reply");
}
}
State, review and files, from C#
Live — send the step for review, then paste a screenshot into it.
GetStateAsync reads what the document holds when the app needs to know — its HTML, its
text, its length and whether it is empty — and SetEnabledAsync stops the reader editing,
or lets them again; a page that renders the textarea's disabled needs neither call.
InsertVideoAsync(id, url, label) puts a video where the caret was, here from a button of the
app's own: data-editor-formats names video, which the toolbar does not offer.
A file pasted or dropped into the document is never inlined. The editor sends
@onsedna-editor-file with an id, a name, a type and a size for each, and keeps the last few;
OpenFileAsync(id, fileId) opens one as a stream reference, which
OpenReadStreamAsync(maxAllowedSize) reads. This one counts the bytes and lists the file as an
attachment. An app uploads them there, and answers an image meant for the text with
InsertImageAsync once it has its address.
@inject ISednaEditors Editors
@* data-editor-formats names video, which the toolbar does not offer, so the app's own button
below can put one in. A screenshot pasted or dropped in arrives as sedna-editor-file. *@
<div class="sedna-stack">
<div class="editor" data-editor id="runbook-step" data-editor-formats="bold list video"
@onsedna-editor-file="FilesArrived">
<div class="editor-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="bold" aria-label="Bold"><i class="ri-bold"></i></button>
<button class="btn btn--ghost btn--icon btn--sm" type="button" data-editor-format="list" value="ordered" aria-label="Numbered list"><i class="ri-list-ordered"></i></button>
</div>
<div class="editor-body prose" data-editor-body></div>
<textarea data-editor-value hidden aria-label="Runbook step" @bind="_step"></textarea>
</div>
<div class="toolbar">
<div class="toolbar-filters">
<button class="btn btn--sm" type="button" disabled="@_inReview" @onclick="AddVideo"><i class="ri-video-add-line"></i> <span>Add the training video</span></button>
@if (_inReview)
{
<button class="btn btn--sm" type="button" @onclick="BackToEditing"><i class="ri-edit-line"></i> <span>Back to editing</span></button>
}
else
{
<button class="btn btn--go btn--solid btn--sm" type="button" @onclick="SendForReview"><i class="ri-send-plane-line"></i> <span>Send for review</span></button>
}
</div>
<span class="toolbar-count" role="status">@_status</span>
</div>
@if (_attachments.Count > 0)
{
<ul class="list list--cards" aria-label="Attachments">
@foreach (var file in _attachments)
{
<li>
<div class="list-row">
<i class="ri-image-line" aria-hidden="true"></i>
<span class="list-main">
<span class="list-title">@file.Name</span>
<span class="list-sub">@file.Type · @file.Bytes.ToString("N0") bytes read</span>
</span>
</div>
</li>
}
</ul>
}
</div>
@code {
private const string Id = "runbook-step";
private const int Shortest = 40;
private sealed record Attachment(string Name, string Type, long Bytes);
private string _step = "<p>Check the racking label before the pallet leaves the dock.</p>";
private bool _inReview;
private string _status = "Paste or drop a screenshot to attach it.";
private readonly List<Attachment> _attachments = [];
// What the document holds now, read when it is needed rather than tracked on every change.
private async Task SendForReview()
{
var state = await Editors.GetStateAsync(Id);
if (state is null) return;
if (state.Length < Shortest)
{
_status = $"{state.Length} characters: a step needs at least {Shortest}.";
return;
}
// The document stays on screen, read-only, while someone reviews it.
await Editors.SetEnabledAsync(Id, false);
_inReview = true;
_status = $"Sent for review: {state.Length} characters.";
}
private async Task BackToEditing()
{
await Editors.SetEnabledAsync(Id, true);
_inReview = false;
_status = "Editing again.";
}
// Where the caret was. The address is the app's own, a file its video library serves;
// this site serves none, so the player here has nothing to play.
private async Task AddVideo()
{
var after = await Editors.InsertVideoAsync(Id, "media/receiving-a-pallet.mp4", "Receiving a pallet at dock 4");
_status = after is null ? "The editor did not keep the video." : "Video added.";
}
// The editor inlines no file. Each one is read here by its id, as a stream: this is where
// an app uploads the bytes and, for an image in the text, answers with InsertImageAsync.
private async Task FilesArrived(SednaEditorFileEventArgs e)
{
foreach (var file in e.Files)
{
var reference = await Editors.OpenFileAsync(Id, file.Id);
if (reference is null) continue;
await using (reference)
{
await using var bytes = await reference.OpenReadStreamAsync(maxAllowedSize: 10_000_000);
using var copy = new MemoryStream();
await bytes.CopyToAsync(copy);
_attachments.Add(new(file.Name == "" ? "Pasted image" : file.Name, file.Type, copy.Length));
}
}
_status = $"{_attachments.Count} attached.";
}
}
Controls
| Control | Does |
|---|---|
data-editor-format="bold", italic, underline, strike, code | Toggles the format on the selection. |
data-editor-format="script" with value="sub" or "super" | Subscript, superscript. |
data-editor-format="header" with value="1"–"6", or on a <select> | A heading level; "" is a paragraph. |
data-editor-format="list" with value="bullet", "ordered" or "check" | A list. |
data-editor-format="blockquote", "code-block" | A quote, a code block. |
data-editor-format="align" with value="center", "right" or "justify" | Alignment; no value is the start edge. |
data-editor-format="link" | Opens the link form. |
data-editor-format="image", "video" | Sends sedna-editor-media, then opens the media form. |
data-editor-action="undo", "redo", "indent", "outdent", "clean" | Undo, redo, one level in or out, and removing the formatting from the selection. |
data-editor-formats="bold italic link" on the editor | The formats it accepts, instead of the ones its toolbar offers. |
data-editor-eager on the editor | Starts it at once, rather than when it scrolls near. |
Events
| Event | Detail |
|---|---|
sedna-editor-ready | { html, text, length, isEmpty }, once the document has loaded. |
sedna-editor-change | The same, after each change. |
sedna-editor-media | { kind, src, alt }, cancelable, before the media form opens: the image or video under the caret, or empty strings. |
sedna-editor-file | { files: [{ id, name, type, size }], index }, when an image or a video file is pasted or dropped. Nothing is inserted: read each by its id. |
the textarea's input, change | As a textarea sends them: at each change, and when the focus leaves after one. |
data-editor-state on the editor is loading, then ready, or
error when the markup has no textarea or no body. The reference is docs/editor.md.