<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ThomasNowHere</title>
    <description>The latest articles on DEV Community by ThomasNowHere (@thomasnowheredev).</description>
    <link>https://dev.to/thomasnowheredev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3839649%2F0fbc4ad6-22d9-4a1a-be95-50349cfc6140.png</url>
      <title>DEV Community: ThomasNowHere</title>
      <link>https://dev.to/thomasnowheredev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thomasnowheredev"/>
    <language>en</language>
    <item>
      <title>Adding Markdown to the Editor: Paste, Export, and What Gets Lost</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Fri, 10 Jul 2026 19:50:55 +0000</pubDate>
      <link>https://dev.to/thomasnowheredev/adding-markdown-to-the-editor-paste-export-and-what-gets-lost-19hg</link>
      <guid>https://dev.to/thomasnowheredev/adding-markdown-to-the-editor-paste-export-and-what-gets-lost-19hg</guid>
      <description>&lt;p&gt;A surprising amount of the text people paste into a rich text editor is Markdown. It comes from ChatGPT answers, GitHub READMEs, Obsidian notes, Slack drafts. The structure is right there in the text: &lt;code&gt;##&lt;/code&gt; headings, &lt;code&gt;- [ ]&lt;/code&gt; task lists, pipe tables. And most editors paste it as what it technically is, a wall of plain text with funny punctuation, leaving the user to rebuild by hand the structure they can already see.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://domternal.dev/v1/extensions/markdown/" rel="noopener noreferrer"&gt;&lt;code&gt;@domternal/extension-markdown&lt;/code&gt;&lt;/a&gt;, new in 0.12.0, closes that gap in both directions. Markdown-looking pastes convert to real blocks, two commands and a headless API cover programmatic import, and the serializer turns any document back into GitHub-flavored Markdown, telling you exactly what could not survive the trip. Here's how it works, including the parts that are deliberately conservative.&lt;/p&gt;

&lt;h2&gt;
  
  
  A paste that knows when to do nothing
&lt;/h2&gt;

&lt;p&gt;The dangerous part of Markdown paste isn't the conversion, it's the false positive. Nobody wants an editor that mangles a regular sentence because it happened to contain an asterisk. So the paste handler is built around refusal, and converts only when all of these hold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The clipboard has no HTML flavor.&lt;/strong&gt; A paste from Google Docs, a web page, or VS Code carries &lt;code&gt;text/html&lt;/code&gt;, and ProseMirror's own HTML paste preserves more fidelity than any Markdown reparse could. Those pastes are never touched. This also means you can't demo the feature by copying rendered Markdown from a web page: it has to be plain text, which is exactly the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The text actually looks like Markdown.&lt;/strong&gt; At least one block marker (&lt;code&gt;#&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, a list marker, &lt;code&gt;1.&lt;/code&gt;, a code fence, &lt;code&gt;$$&lt;/code&gt;, a checkbox, a pipe-table row, a thematic break) or inline span (&lt;code&gt;**bold**&lt;/code&gt;, &lt;code&gt;`code`&lt;/code&gt;, &lt;code&gt;~~strike~~&lt;/code&gt;, a link or image) has to be present. Plain prose passes through untouched, and a bare URL is left alone so the Link extension's paste handling can turn it into a link, as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The selection isn't inside a code block.&lt;/strong&gt; Pasting Markdown into a code block keeps it literal, because inside a code block, Markdown source is the content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the parser itself fails for any reason, the handler falls back to the default plain-text paste rather than letting anything escape. And when a paste does convert, one more detail matters: a single-paragraph paste like &lt;code&gt;has **bold** inline&lt;/code&gt; inserts as inline content and merges into the text at the cursor, while multi-block Markdown replaces the selection as blocks. Either way it's one transaction, so undo restores the exact pre-paste document in a single step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4cp09fjdu3zh0bgor33s.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4cp09fjdu3zh0bgor33s.gif" alt="A cursor clicks Copy on a card holding plain-text Markdown, then clicks into the editor and presses Cmd+V; the pasted text instantly converts to a real heading, task list with checkboxes, table, and highlighted code block&lt;br&gt;
" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you'd rather not have any of this, it's one option: &lt;code&gt;Markdown.configure({ paste: false })&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Import as an API
&lt;/h2&gt;

&lt;p&gt;The same parser backs two commands, for the cases where the Markdown comes from your code instead of the clipboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;## Hello&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s1"&gt;- [x] done&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;- [ ] open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMarkdownContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;# Fresh document&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;insertMarkdown&lt;/code&gt; parses and inserts at the selection with the same single-paragraph-merges-inline behavior as paste. &lt;code&gt;setMarkdownContent&lt;/code&gt; replaces the whole document and passes its options through to &lt;code&gt;setContent&lt;/code&gt;, so &lt;code&gt;{ emitUpdate: false }&lt;/code&gt; works the way you'd expect when you're loading content and don't want to trigger save logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  An export that tells you what it lost
&lt;/h2&gt;

&lt;p&gt;Serializing a rich document to Markdown is inherently lossy. An editor document can carry text alignment, colors, underline, merged table cells; Markdown can express none of those. There are two common ways to handle that, and I didn't like either: throw and refuse to export, or silently drop the formatting and let the user discover the damage later.&lt;/p&gt;

&lt;p&gt;Domternal's serializer takes a third path. It always produces the best Markdown it can, keeps the content readable, and reports every fidelity loss through a warnings channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getMarkdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;downloadMarkdown&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// warnings: [{ code: 'unsupported-mark', message: 'Mark type "underline" ...', nodeType: 'underline' }]&lt;/span&gt;

&lt;span class="nf"&gt;downloadMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// triggers a .md download, returns the same result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are four warning codes: &lt;code&gt;unsupported-node&lt;/code&gt; and &lt;code&gt;unsupported-mark&lt;/code&gt; for content with no Markdown mapping, &lt;code&gt;lossy-attribute&lt;/code&gt; for things like alignment or image resize dimensions, and &lt;code&gt;lossy-structure&lt;/code&gt; for content that had to be flattened, like a toggle block becoming a bold summary paragraph followed by its content, or a mention becoming plain &lt;code&gt;@label&lt;/code&gt; text. Warnings are deduplicated, so a document with forty underlined spans reports the underline loss once.&lt;/p&gt;

&lt;p&gt;What you do with them is a product decision: show a toast, list them in an export dialog, or ignore them. The point is that the information exists, so "export to Markdown" never has to mean "find out next week what your document quietly dropped".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjv9p2yca7xxk8sw8smut.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjv9p2yca7xxk8sw8smut.gif" alt="A paragraph in the editor is underlined and exported to Markdown; the source pane fills with GitHub-flavored Markdown and a status line reports an unsupported-mark warning because Markdown cannot express underline&lt;br&gt;
" width="760" height="428"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The GFM surface, and the round-trip promise
&lt;/h2&gt;

&lt;p&gt;The serializer and parser cover the full Notion-style schema: headings, bullet and ordered lists (with &lt;code&gt;start&lt;/code&gt;), GFM task lists, blockquotes, fenced code with a language (the fence grows when the code itself contains backticks), pipe tables with column alignment, images with alt and title, links and autolinks, bold, italic, strikethrough, inline code, hard breaks, horizontal rules, LaTeX math, and emoji.&lt;/p&gt;

&lt;p&gt;For that supported subset, round trips are exact and covered by tests: &lt;code&gt;serialize(parse(markdown))&lt;/code&gt; reproduces the input, and &lt;code&gt;parse(serialize(doc))&lt;/code&gt; rebuilds an equal document. Exactness is what makes the feature trustworthy; "mostly the same after a round trip" is how documents rot.&lt;/p&gt;

&lt;p&gt;My favorite small detail is the math currency guard. &lt;code&gt;$...$&lt;/code&gt; is the standard inline LaTeX delimiter, but it's also how people write prices. The inline rule refuses whitespace just inside the dollars and a digit right after the closing one, so &lt;code&gt;price $5 and $10 total&lt;/code&gt; stays text while &lt;code&gt;$e^{i\pi}+1=0$&lt;/code&gt; becomes an equation. Escaping runs in the other direction too: text that merely looks like Markdown (&lt;code&gt;|&lt;/code&gt;, &lt;code&gt;$&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, entity-like &lt;code&gt;&amp;amp;&lt;/code&gt; sequences) is escaped on export so it round-trips exactly, which also keeps the output well-behaved on renderers that allow raw HTML.&lt;/p&gt;
&lt;h2&gt;
  
  
  Headless, and schema-adaptive
&lt;/h2&gt;

&lt;p&gt;Parsing and serialization don't need an editor instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parseMarkdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;serializeMarkdown&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;# Title&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s1"&gt;Body.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;serializeMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs in Node scripts, tests, or on a server, against any schema. And the parser adapts to whatever schema you give it: the handler set is derived from what the schema actually contains, so Markdown features without a counterpart degrade to readable text instead of failing. Paste a table into an editor without the &lt;a href="https://domternal.dev/v1/nodes/table/" rel="noopener noreferrer"&gt;Table extension&lt;/a&gt; and you get the rows as plain text, not an exception. &lt;code&gt;~~strike~~&lt;/code&gt; without the Strike mark keeps its literal tildes.&lt;/p&gt;

&lt;p&gt;Custom content plugs into the same machinery. A node serializer is a function that writes Markdown, a mark spec declares its delimiters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;specs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;callout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrapBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;marks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;kbd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;`&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;`&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a mapping, a custom node flattens to its content with a warning. A custom node never breaks the export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up
&lt;/h2&gt;

&lt;p&gt;One package, no configuration required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @domternal/extension-markdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StarterKit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getMarkdown&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;StarterKit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same setup works through the React, Vue, Angular, and vanilla wrappers, and it composes with the &lt;a href="https://domternal.dev/v1/guides/notion-mode/" rel="noopener noreferrer"&gt;Notion mode&lt;/a&gt; stack: the paste handler and SmartPaste divide the work cleanly, since SmartPaste ignores plain-text pastes and the Markdown heuristic rejects what isn't Markdown. There's a &lt;a href="https://domternal.dev/v1/extensions/markdown/#live-playground" rel="noopener noreferrer"&gt;live playground&lt;/a&gt; on the docs page where you can round-trip a document both ways without installing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping in 0.12.0
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@domternal/extension-markdown&lt;/code&gt; is new in &lt;strong&gt;0.12.0&lt;/strong&gt;, MIT licensed like everything else. The same release taught plugin views to dispatch transactions during editor construction and gave the framework wrappers a &lt;code&gt;history: false&lt;/code&gt; option, groundwork for editors that bring their own undo. The full list is in the &lt;a href="https://domternal.dev/v1/changelog/" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://domternal.dev/v1/extensions/markdown/" rel="noopener noreferrer"&gt;Markdown documentation&lt;/a&gt; - options, commands, the warnings reference, headless usage, and the live playground&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://domternal.dev/examples/" rel="noopener noreferrer"&gt;Live examples&lt;/a&gt; - the homepage editors now convert Markdown pastes too&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; - MIT licensed, issues and stars welcome&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you wire the warnings channel into an export UI, or feed the parser Markdown from somewhere I haven't thought of, I'd genuinely like to see it. Lossy conversions are only honest when someone is checking the loss report.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a Comment Box Found Four Bugs in My Editor</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Mon, 15 Jun 2026 08:47:34 +0000</pubDate>
      <link>https://dev.to/thomasnowheredev/building-a-comment-box-found-four-bugs-in-my-editor-55oi</link>
      <guid>https://dev.to/thomasnowheredev/building-a-comment-box-found-four-bugs-in-my-editor-55oi</guid>
      <description>&lt;p&gt;I recently wrote the third tutorial for &lt;a href="https://domternal.dev/v1/introduction/" rel="noopener noreferrer"&gt;Domternal&lt;/a&gt;, my MIT-licensed ProseMirror editor: &lt;a href="https://domternal.dev/tutorials/build-a-comment-box-with-mentions-in-vue/" rel="noopener noreferrer"&gt;a comment box with @mentions in Vue&lt;/a&gt;. The core package alone runs 2,441 unit tests. There are four demo apps, one per supported framework, each with its own Playwright suite. I felt pretty good about coverage.&lt;/p&gt;

&lt;p&gt;The comment box found four real bugs in one afternoon.&lt;/p&gt;

&lt;p&gt;None of them were exotic. Every one had been sitting in shipped releases, invisible, because every test and every demo I had ever written shared one assumption the comment box didn't: that the editor is a big, tall document. A one-line composer is a different shape of consumer, and a different shape is exactly what coverage numbers can't measure. Here's what it caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: The placeholder that wasn't there
&lt;/h2&gt;

&lt;p&gt;The composer starts empty, so the first thing you should see is the placeholder prompt. Instead: nothing. Click into the editor: still nothing, because a click only dispatches a transaction when the selection actually changes, and in an empty document there is nowhere else for it to go. Type one character and delete it: there it is.&lt;/p&gt;

&lt;p&gt;The placeholder is a ProseMirror decoration (presentational markup the editor layers over the document, recomputed from editor state rather than stored in it), and the plugin computing it had a guard that looked completely reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;decorations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;selection&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DecorationSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isEditable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The catch is &lt;em&gt;when&lt;/em&gt; ProseMirror first asks for decorations: during the &lt;code&gt;EditorView&lt;/code&gt; constructor, while it draws the initial document. At that moment &lt;code&gt;editor.view&lt;/code&gt; is still unassigned, because the assignment only happens after the constructor returns. So the very first draw gets an empty decoration set, and since decorations are only recomputed on a state update, the placeholder stays invisible until the first transaction. The one-line repro that confirmed it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// a no-op transaction, placeholder appears&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Why did no test or demo ever catch this? Every demo seeds the editor with content, and every placeholder test called the decoration function manually, after construction, when the view exists. The fix was to make &lt;code&gt;editor.isEditable&lt;/code&gt; safe before the view exists (it falls back to the configured option) and drop the view check from the plugin, plus a regression test that asserts &lt;code&gt;[data-placeholder]&lt;/code&gt; is in the DOM immediately after &lt;code&gt;new Editor(...)&lt;/code&gt;, before anything else happens.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug 2: The popup that got clipped
&lt;/h2&gt;

&lt;p&gt;Type &lt;code&gt;@&lt;/code&gt; in the composer and the mention popup opens, except it gets sliced off at the editor's bottom edge: two rows visible, the third cut mid-letter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5izws2b49faiaeuadfre.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5izws2b49faiaeuadfre.png" alt="The mention suggestion popup cut off at the editor's bottom edge, with the third item half visible" width="524" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Domternal positions all floating UI (suggestion popups, bubble menus, popovers) &lt;code&gt;position: absolute&lt;/code&gt; &lt;em&gt;inside&lt;/em&gt; the editor wrapper. That's a deliberate architecture choice: during scrolling the popup moves with the content for free, on the compositor, with no JavaScript repositioning and no jitter. The theme also set &lt;code&gt;overflow: hidden&lt;/code&gt; on that same wrapper, for rounded corners.&lt;/p&gt;

&lt;p&gt;In a full-page document those two choices never meet: the popup opens next to the caret, well inside the box. In a composer the box is 80 pixels tall and the full six-person popup needs 190. Even filtered down to three people it measured 64 pixels past the editor's bottom edge. Clipped.&lt;/p&gt;

&lt;p&gt;The fix moves the clipping one element down, onto the editor's content wrapper, so the document still crops at the rounded corners but floating UI can escape the box:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.dm-editor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* overflow: hidden is gone from the wrapper itself */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.dm-editor&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;.ProseMirror&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inherit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;On released versions the tutorial documents the one-line workaround (&lt;code&gt;overflow: visible&lt;/code&gt; on the composer's editor), which becomes a harmless no-op once this ships in 0.9.0.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug 3: The mouse that wouldn't let go
&lt;/h2&gt;

&lt;p&gt;This one I only found because I record the tutorial demos with a scripted, human-like cursor. The script typed &lt;code&gt;@ma&lt;/code&gt;, pressed ArrowDown to highlight the second person, and pressed Enter. The video showed the highlight never moving and Enter inserting the wrong person.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm5uvv7y56ysny4qwgep3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm5uvv7y56ysny4qwgep3.png" alt="Five consecutive video frames after ArrowDown: the highlight stays on the first item the whole time" width="800" height="88"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same steps in a plain Playwright test passed. The difference between the two runs took a while to find: in the recording, the real mouse pointer was resting where it had last clicked, and the popup happened to open underneath it.&lt;/p&gt;

&lt;p&gt;The suggestion list re-renders on every change, including the re-render that ArrowDown itself triggers. Rebuilding the item buttons under a stationary pointer makes the browser fire a synthetic &lt;code&gt;mouseenter&lt;/code&gt; on whichever new element ends up under the cursor, with zero physical mouse movement. The renderer treated &lt;code&gt;mouseenter&lt;/code&gt; as "the user is hovering this item" and moved the selection right back. Keyboard presses ArrowDown, re-render happens, phantom hover undoes it. Every time.&lt;/p&gt;

&lt;p&gt;The fix is small and a classic of autocomplete UIs: select on &lt;code&gt;mousemove&lt;/code&gt; instead of &lt;code&gt;mouseenter&lt;/code&gt;. Real hovering always produces mousemove events; a DOM swap under a motionless pointer never does. The regression tests now assert that a synthetic &lt;code&gt;mouseenter&lt;/code&gt; alone does not move the selection.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxllbc1jfjl4i0scgoavo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxllbc1jfjl4i0scgoavo.png" alt="Three frames after the fix: ArrowDown moves the highlight to the second item and Enter inserts it" width="800" height="139"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug 4: The calc() that ate my padding
&lt;/h2&gt;

&lt;p&gt;The composer needed smaller padding than a document, and the theme exposes a custom property for exactly that. The theme used it like this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.dm-editor&lt;/span&gt; &lt;span class="nc"&gt;.ProseMirror&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--dm-editor-padding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;padding-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--dm-editor-padding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--dm-editor-padding-top-extra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0px&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Works beautifully until someone sets the variable to a two-value shorthand:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--dm-editor-padding&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;4&lt;/span&gt;&lt;span class="nt"&gt;rem&lt;/span&gt; &lt;span class="err"&gt;4&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="nt"&gt;rem&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;padding&lt;/code&gt; line is fine. The &lt;code&gt;padding-top&lt;/code&gt; line substitutes to &lt;code&gt;calc(4rem 4.8rem + 0px)&lt;/code&gt;, which is invalid. And here's the cruel part of the CSS spec: a declaration that becomes invalid &lt;em&gt;at computed-value time&lt;/em&gt; doesn't fall back to the earlier &lt;code&gt;padding&lt;/code&gt; declaration in the cascade. It computes to the property's default. &lt;code&gt;padding-top: 0&lt;/code&gt;. Silently. No console warning, nothing in DevTools except a value you didn't set.&lt;/p&gt;

&lt;p&gt;The punchline: all four of Domternal's own demo apps set exactly that two-value shorthand. Every demo had been rendering with zero top padding and nobody noticed, because a 4.8rem side padding makes a missing 4.5rem top padding look like a slightly snug design choice rather than a bug.&lt;/p&gt;

&lt;p&gt;The fix adds a dedicated single-value variable with a fallback chain, so existing single-value users keep their behavior and shorthand users get a working knob:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;padding-top&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;calc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--dm-editor-padding-top&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--dm-editor-padding&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--dm-editor-padding-top-extra&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  What actually caught them
&lt;/h2&gt;

&lt;p&gt;Not one of these four bugs was caught by adding more tests to the shapes I already had, because tests encode the consumer you imagined while writing them. All my imagined consumers were documents: tall, content-seeded, mouse-and-keyboard in the usual places. Every core test that existed at the time kept passing before, during, and after all four bugs.&lt;/p&gt;

&lt;p&gt;What caught them was a &lt;em&gt;new shape&lt;/em&gt;: an editor that is one line tall, starts empty, hijacks Enter, and gets driven by a recorded cursor that behaves like a human hand instead of a test runner. Placeholder-on-first-paint only matters when the editor starts empty. Popup clipping only matters when the editor is shorter than the popup. The hover bug only appears when a real pointer rests where a popup opens. The padding bug was visible all along, but only a composer made it look broken instead of stylistic.&lt;/p&gt;

&lt;p&gt;The placeholder and hover fixes now ship with regression tests that fail on the old code, and the theme changes were run through the suggestion and Notion e2e suites of all four demo apps, so the shapes stay covered. But the general lesson stands: if your library has only ever been used the way you use it, you don't know how it behaves. Write the tutorial. Build the weird little composer. Your test suite will thank you, right after it embarrasses you.&lt;/p&gt;

&lt;p&gt;All four fixes land in Domternal 0.9.0, the next release. If you want the comment box that started all this, the full walkthrough is in &lt;a href="https://domternal.dev/tutorials/build-a-comment-box-with-mentions-in-vue/" rel="noopener noreferrer"&gt;the Vue tutorial&lt;/a&gt;, and the editor itself is &lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;MIT-licensed on GitHub&lt;/a&gt;.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/domternal" rel="noopener noreferrer"&gt;
        domternal
      &lt;/a&gt; / &lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;
        domternal
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Modern, extensible rich text editor toolkit built on ProseMirror. Classic and Notion-style editors out of the box. Or go headless and assemble from 65+ tree-shakeable extensions. First-class wrappers for Angular, React, Vue, and Vanilla.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Domternal&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://domternal.dev" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/87f93f04fba5e48bf6e9309f7af1f2c1191f3c9c2592264ea8884ee063b35e29/68747470733a2f2f646f6d7465726e616c2e6465762f726561646d652f726561646d652d62616e6e65722e706e673f763d34" alt="Domternal Editor"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A rich text editor toolkit built on &lt;a href="https://prosemirror.net/" rel="nofollow noopener noreferrer"&gt;ProseMirror&lt;/a&gt;, with a headless core and first-class &lt;strong&gt;Angular&lt;/strong&gt;, &lt;strong&gt;React&lt;/strong&gt;, &lt;strong&gt;Vue&lt;/strong&gt;, and &lt;strong&gt;Vanilla&lt;/strong&gt; components. Take the core alone and drive the DOM yourself, add the toolbar and theme for a finished editor, or mount the components for your framework. Notion-style block editing, drag handle, slash menu and floating outline included, and every package is tree-shakeable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/domternal/domternal/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/08cef40a9105b6526ca22088bc514fbfdbc9aac1ddbf8d4e6c750e3a88a44dca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d626c75652e737667" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://github.com/domternal/domternal/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/domternal/domternal/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://codecov.io/gh/domternal/domternal" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/1b7e9c83049e12b8532c7e7f3573d1a9a1f27f96886c38afd18bdcab1e476c0a/68747470733a2f2f636f6465636f762e696f2f67682f646f6d7465726e616c2f646f6d7465726e616c2f67726170682f62616467652e737667" alt="codecov"&gt;&lt;/a&gt;
&lt;a href="https://www.npmjs.com/package/@domternal/core" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/aa2fd1ad9ec421f45a27bbbc45de3db573c9cf61f88b4960229d57e39dfc4348/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f40646f6d7465726e616c2f636f72652e7376673f6c6162656c3d253430646f6d7465726e616c253246636f7265" alt="npm"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://domternal.dev" rel="nofollow noopener noreferrer"&gt;Website&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://domternal.dev/v1/getting-started" rel="nofollow noopener noreferrer"&gt;Getting Started&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://domternal.dev/v1/packages" rel="nofollow noopener noreferrer"&gt;Packages &amp;amp; Bundle Size&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://domternal.dev/playground" rel="nofollow noopener noreferrer"&gt;Live examples&lt;/a&gt;&lt;/strong&gt; - full editors for Angular, React, Vue, and Vanilla, editable in the browser&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Headless core&lt;/strong&gt; - use with any framework or vanilla JS/TS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Angular components&lt;/strong&gt; - editor, toolbar, bubble menu, floating menu, emoji picker, notion color picker (Angular 17.1+, signals, OnPush, zoneless-ready)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React components&lt;/strong&gt; - composable &lt;code&gt;Domternal&lt;/code&gt; component, toolbar, bubble menu, floating menu, emoji picker, notion color picker, custom node views (React 18+)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vue components&lt;/strong&gt; - composable &lt;code&gt;Domternal&lt;/code&gt; component, &lt;code&gt;useEditor&lt;/code&gt;/&lt;code&gt;useEditorState&lt;/code&gt;…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>prosemirror</category>
      <category>debugging</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Notion-Style Block Editor for Any Framework</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Thu, 04 Jun 2026 18:58:34 +0000</pubDate>
      <link>https://dev.to/thomasnowheredev/notion-style-block-editor-for-any-framework-fk6</link>
      <guid>https://dev.to/thomasnowheredev/notion-style-block-editor-for-any-framework-fk6</guid>
      <description>&lt;p&gt;The "Notion experience" is a set of block-level interactions on top of a structured document: hover a block for a drag handle, grab it to reorder with a live drop line, press &lt;code&gt;/&lt;/code&gt; for an insert menu, or "Turn into" to convert a heading into a to-do list. The hard part is shipping all of that headless, so the same block editor works in every framework instead of being locked to one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;Domternal&lt;/a&gt; takes a different route: the entire block layer is plain ProseMirror plugins, not framework components. The slash menu, hover handle, drag-to-reorder, "Turn into", block and inline colors, to-do nesting, toggles, and a scroll-spy table of contents are all headless and MIT licensed. They behave identically whether you mount the editor through the React, Angular, Vue, or Vanilla wrapper. And because each one is a separate extension, you load only the pieces you actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "block editing" actually is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxaprr8tfcflu40yiphhj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxaprr8tfcflu40yiphhj.gif" alt="Domternal block context menu open over the editor, showing Delete, Duplicate, Copy link, a Colors palette, and Turn into options" width="500" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notion's magic isn't one feature. It's a handful of small interactions that all agree on the same idea: every paragraph, heading, list, and quote is a &lt;em&gt;block&lt;/em&gt; you can summon, transform, recolor, and move without ever touching the keyboard's arrow keys if you don't want to.&lt;/p&gt;

&lt;p&gt;In Domternal that's a few extensions working together. &lt;code&gt;@domternal/extension-block-menu&lt;/code&gt; carries most of the interaction layer: the slash menu, the hover handle, drag-to-reorder, the block context menu ("Turn into", Duplicate, Copy link), keyboard reordering, and a smart paste that keeps block structure intact instead of flattening it to plain text. &lt;code&gt;@domternal/extension-toc&lt;/code&gt; adds the scroll-spy outline and an inline &lt;code&gt;/toc&lt;/code&gt; block, &lt;code&gt;@domternal/extension-details&lt;/code&gt; adds collapsible toggles, and a few core pieces fill in the rest: &lt;code&gt;BlockColor&lt;/code&gt;, the inline &lt;code&gt;NotionColorPicker&lt;/code&gt;, &lt;code&gt;UniqueID&lt;/code&gt; for stable block ids, and the to-do list nodes.&lt;/p&gt;

&lt;p&gt;They're all just extensions. They register ProseMirror plugins and contribute items through the same &lt;code&gt;addFloatingMenuItems()&lt;/code&gt; hook the core uses. Nothing in them knows or cares which framework renders the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slash menu
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiiwhbrcy6drdm1jvyvcf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiiwhbrcy6drdm1jvyvcf.gif" alt="Domternal slash menu: typing /head filters the insert menu to Heading 1, 2, and 3, and selecting one turns the line into a heading" width="664" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Type &lt;code&gt;/&lt;/code&gt; at the start of a line and a filtered popup of insertable blocks appears under the cursor. Keep typing to filter: &lt;code&gt;/head&lt;/code&gt; narrows to the headings, &lt;code&gt;/todo&lt;/code&gt; jumps to the to-do list. Arrow keys move the selection, Enter inserts, and the &lt;code&gt;/query&lt;/code&gt; text you typed is deleted before the block lands so you never have to clean up after it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SlashCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;SlashCommand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;char&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// the trigger character, '/' by default&lt;/span&gt;
  &lt;span class="na"&gt;invalidNodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;codeBlock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// don't hijack '/' inside code&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The list of items isn't hard-coded into the slash menu. It's collected from whatever extensions you loaded, so the menu grows automatically as you add features. Each item can declare a &lt;code&gt;hideWhenInside&lt;/code&gt; rule too, which is how "Bullet list" politely disappears from the menu when your cursor is already inside one.&lt;/p&gt;

&lt;p&gt;One detail that's pure Notion: the menu only opens when you actually &lt;em&gt;type&lt;/em&gt; &lt;code&gt;/&lt;/code&gt;. Pasting text that contains a slash, inserting it programmatically, undo/redo, or clicking next to a &lt;code&gt;/&lt;/code&gt; that's already sitting there will not reopen it, so a dismissed slash just becomes plain text. Opening the menu also broadcasts a single "dismiss everything else" signal, so you never end up with two floating menus fighting over the same corner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The block handle: hover, grab, add
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4wa7ydw9o0228rlraof8.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4wa7ydw9o0228rlraof8.gif" alt="Hovering the left gutter reveals the Domternal block handle; clicking the plus button opens the insert menu" width="800" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hover the left gutter next to any block and a handle fades in. It has two buttons: a six-dot grip and a plus.&lt;/p&gt;

&lt;p&gt;The plus inserts an empty paragraph below and opens the insert menu, so adding a block is one click. The grip does two jobs depending on how you use it: click it to open the block's context menu, or drag it to move the block.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockHandle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// list items and task items get their own handles, Notion-style&lt;/span&gt;
  &lt;span class="na"&gt;hideDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// grace period so the handle doesn't vanish as you reach for it&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;nested: true&lt;/code&gt; is worth a sentence. With it off, only top-level blocks are draggable. With it on, individual list items and task items resolve their own handles, so you can grab one bullet out of a list and drop it somewhere else, exactly like Notion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The block context menu: Turn into, Duplicate, Copy link
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F69l69q0oyam7bf253u4z.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F69l69q0oyam7bf253u4z.gif" alt="Opening a paragraph's block context menu and using Turn into to convert it into a heading" width="640" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click the grip instead of dragging it and the block context menu opens. It's the &lt;code&gt;BlockContextMenu&lt;/code&gt; extension, and it carries the actions you reach for most: Delete, Duplicate, Copy link, a Colors submenu, and a "Turn into" section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn into&lt;/strong&gt; converts the current block to another type. The defaults cover the blocks people actually convert between: Paragraph, Heading 1, Heading 2, Heading 3, Bullet list, Ordered list, To-do list, Quote, and Code block.&lt;/p&gt;

&lt;p&gt;The menu is smart about what it offers. It hides the block type you're already in (converting a paragraph into a paragraph is a no-op), it hides list and quote targets when an ancestor is already that type, and it won't offer Quote inside a list item because the schema doesn't allow a blockquote there. You only ever see conversions that will actually work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate&lt;/strong&gt; copies the block with its content, marks, and attributes intact, and regenerates the block's unique id on the copy so deep links never collide. &lt;strong&gt;Delete&lt;/strong&gt; removes it, and if you delete the last block in the document it drops in a fresh empty paragraph so the editor never ends up in an invalid empty state. Small things, but they're the difference between "feels finished" and "feels like a demo".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockContextMenu&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;BlockContextMenu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;copyLinkEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// show "Copy link" (needs UniqueID for stable block ids)&lt;/span&gt;
  &lt;span class="c1"&gt;// turnIntoTargets: [...] to customize the "Turn into" list&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Copy link&lt;/strong&gt; writes a &lt;code&gt;#block-id&lt;/code&gt; URL to your clipboard. It shows up only when &lt;code&gt;UniqueID&lt;/code&gt; is loaded and the block actually has an id, and it pairs with the table of contents, which reads the URL hash on load and scrolls straight to that block. That's the full Notion "copy link to block" round-trip, headless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drag-to-reorder, and the drop line that gets nesting right
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvp551jgq5mby95u37lr6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvp551jgq5mby95u37lr6.gif" alt="Dragging a list item by its handle to reorder it, then dragging it to the right to nest it as a child with a dashed drop line" width="800" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the part that took the most care. When you drag a block, a line follows the cursor to show where it will land. A flat reorder is easy. The interesting case is lists.&lt;/p&gt;

&lt;p&gt;In Notion, &lt;em&gt;where&lt;/em&gt; you drop horizontally decides the outcome. Drop a block lined up with the list and it becomes a sibling item. Drag it to the right, past the marker, and it becomes a nested child. Domternal mirrors that: the drop indicator is a solid line for a sibling drop and switches to a dashed, indented line once you cross a horizontal threshold into nested-child territory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;nestThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// px from the list item's left edge before a drop nests&lt;/span&gt;
  &lt;span class="na"&gt;autoScroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// scroll the page when you drag near the top or bottom edge&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nestThreshold&lt;/code&gt; is the x-distance you have to cross before the drop commits to nesting. Set it to &lt;code&gt;0&lt;/code&gt; and every drop stays a sibling. There's also an auto-scroll loop so dragging a block to the far end of a long document scrolls the page for you, with the speed ramping up the closer you get to the edge instead of lurching.&lt;/p&gt;

&lt;p&gt;A drop in the gutter, or in the gap between two blocks, still lands on the nearest block instead of quietly doing nothing. And because the drop indicator and the actual drop are computed by the same function, the line never lies about where the block will end up.&lt;/p&gt;

&lt;p&gt;Prefer the keyboard? &lt;code&gt;KeyboardReorder&lt;/code&gt; moves the current block with &lt;code&gt;Mod-Shift-ArrowUp&lt;/code&gt; and &lt;code&gt;Mod-Shift-ArrowDown&lt;/code&gt;, reusing the exact same move logic as the drag path so the two never disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small detail that makes it feel right: Enter shouldn't open a menu
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvo1ikoqtia27877ide94.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvo1ikoqtia27877ide94.gif" alt="Pressing Enter leaves a clean empty line with a faint prompt; the insert menu only appears after typing a slash" width="664" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Early on, the insert menu popped up on every empty line. It sounds helpful. It isn't. In Notion, a blank line is just a blank line with a faint "Press '/' for commands" hint, and the menu only shows when you &lt;em&gt;ask&lt;/em&gt; for it.&lt;/p&gt;

&lt;p&gt;So the floating menu has an opt-in for exactly that behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FloatingMenu&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;FloatingMenu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;requireExplicitTrigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// menu only opens via the + button or by typing '/'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that flag on, pressing Enter gives you a clean empty paragraph and nothing else. The menu is there the instant you click the plus or type a slash, and invisible the rest of the time. It's a tiny change that's the difference between "feels like Notion" and "feels like an editor pretending to be Notion".&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocks that hold other blocks: to-dos and toggles
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fozbyte5b9f264h86bqw0.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fozbyte5b9f264h86bqw0.gif" alt="A Domternal to-do list with a checked item and a nested sub-item indented beneath a checkbox" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl3lljnrgtms1ds3qgz85.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl3lljnrgtms1ds3qgz85.gif" alt="Inserting a toggle block and collapsing then expanding it with the triangle, built on native details and summary elements" width="664" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some blocks aren't just one line: they hold other blocks. A list item or a to-do item is a &lt;em&gt;label&lt;/em&gt; line plus a children zone underneath it: press Enter at the end of the label for a sibling, or add blocks below and they nest &lt;em&gt;under&lt;/em&gt; that single bullet or checkbox, exactly like Notion's indented sub-content. The same &lt;code&gt;paragraph block*&lt;/code&gt; model backs bullet lists, ordered lists, and to-do lists, so nesting behaves identically across all three. To-dos get the shortcuts you'd expect: &lt;code&gt;[ ]&lt;/code&gt; and &lt;code&gt;[x]&lt;/code&gt; start an unchecked or checked item, &lt;code&gt;Mod-Enter&lt;/code&gt; ticks the current one, and &lt;code&gt;Mod-Shift-9&lt;/code&gt; toggles a to-do list.&lt;/p&gt;

&lt;p&gt;The toggle is the other one: Notion's little triangle that collapses a block and everything tucked under it. It ships as &lt;code&gt;@domternal/extension-details&lt;/code&gt;, built on the native &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; elements so it's accessible and degrades to plain HTML, and it shows up in the slash menu as "Toggle block".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Details&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-details&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// remember the open/closed state in the document&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Toggles cooperate with the rest of the block layer too: convert into and out of them from the context menu, drag them around with their contents, and when you follow a table-of-contents link to a heading inside a collapsed toggle, the toggle opens itself so the heading is actually visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Color, inline and per-block
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffz9jhz6qu4ne17zkczwu.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffz9jhz6qu4ne17zkczwu.gif" alt="Selecting text and applying a yellow background from the Domternal bubble menu's color picker" width="640" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notion has two kinds of color: a whole-block tint and an inline color on a run of text. Domternal ships both.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BlockColor&lt;/code&gt; tints an entire block's text or background from a fixed nine-color palette - gray, brown, orange, yellow, green, blue, purple, pink, red - wired into the block context menu's Colors section as text and background swatch rows plus a clear button.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockColor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setBlockBgColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;unsetBlockColors&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// clear text + background&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For inline color there's &lt;code&gt;NotionColorPicker&lt;/code&gt;, the swatch panel that drops into the selection (bubble) menu with nine text colors and nine backgrounds. The key design choice is that both store &lt;em&gt;named tokens&lt;/em&gt; (&lt;code&gt;data-text-color="red"&lt;/code&gt;), not raw hex. The theme maps each token to a CSS custom property with separate light and dark values, so the same saved document renders correct, readable colors in both themes without you storing theme-specific markup. And because color is "last action wins", tinting a whole block strips any conflicting inline color underneath it, so you never get unreadable text sitting on a colored block.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scroll-spy outline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz51h57k5spnso78dkoc3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz51h57k5spnso78dkoc3.gif" alt="The floating scroll-spy table-of-contents outline listing the document's headings with the active one highlighted" width="640" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Long documents need a table of contents, and Notion's is the good kind: a column of ticks pinned to the side that expands into full headings on hover, with the tick for the section you're reading highlighted as you scroll.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@domternal/extension-toc&lt;/code&gt; is three extensions you opt into separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;TableOfContents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// collects headings, owns the active-id state&lt;/span&gt;
  &lt;span class="nx"&gt;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the hover-to-expand outline pinned to the side&lt;/span&gt;
  &lt;span class="nx"&gt;TableOfContentsBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// an inline /toc block you can drop in the doc&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-toc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scroll-spy is the part I'm happiest with. It tracks the active heading with an &lt;code&gt;IntersectionObserver&lt;/code&gt;, falls back to a throttled scroll calculation for the edges, and crucially it can follow a &lt;em&gt;container's&lt;/em&gt; scroll, not just the window. That matters because a real app usually scrolls the editor inside a panel, not the whole page. Pass the scroll container and the outline tracks against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// stick to the editor container, or 'viewport' for full-page&lt;/span&gt;
  &lt;span class="na"&gt;activeScrollParent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the element that actually scrolls&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking a tick smooth-scrolls to that heading and updates the URL hash, and it ignores scroll updates for a moment afterward so the highlight lands where you clicked instead of flickering on the way there. Heading IDs come from the core &lt;code&gt;UniqueID&lt;/code&gt; extension, so the outline reads stable ids it doesn't have to generate itself.&lt;/p&gt;

&lt;p&gt;Those are two separate surfaces, by the way. &lt;code&gt;FloatingTocOutline&lt;/code&gt; is the side rail; &lt;code&gt;TableOfContentsBlock&lt;/code&gt; is an inline &lt;code&gt;/toc&lt;/code&gt; block you drop into the document body from the slash menu, rendering the same live outline inline and sharing the rail's active-heading state (with a friendly placeholder until the document has headings). Both route through the same &lt;code&gt;scrollToHeading&lt;/code&gt;, and because it writes the URL hash, a link you copied to a block will, on next load, scroll the reader straight to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;A full Notion-style setup is just a list of extensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @domternal/core @domternal/extension-block-menu @domternal/extension-toc @domternal/extension-details @domternal/theme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StarterKit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UniqueID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BlockColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;FloatingMenu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BlockContextMenu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;SlashCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;KeyboardReorder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SmartPaste&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;TableOfContents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TableOfContentsBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-toc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Details&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/extension-details&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;StarterKit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                     &lt;span class="c1"&gt;// paragraphs, headings, lists, to-dos, history&lt;/span&gt;
    &lt;span class="nx"&gt;UniqueID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                       &lt;span class="c1"&gt;// stable block ids for TOC + copy-link&lt;/span&gt;
    &lt;span class="nx"&gt;FloatingMenu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;requireExplicitTrigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nx"&gt;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nx"&gt;BlockContextMenu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;SlashCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;KeyboardReorder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;SmartPaste&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;BlockColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;TableOfContents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;TableOfContentsBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;p&amp;gt;Press '/' for commands&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the headless core. If you're in a framework, you wrap the same editor in &lt;code&gt;@domternal/react&lt;/code&gt;, &lt;code&gt;@domternal/angular&lt;/code&gt;, &lt;code&gt;@domternal/vue&lt;/code&gt;, or &lt;code&gt;@domternal/vanilla&lt;/code&gt;. The block layer doesn't change. The slash menu, the handles, the drop line, the block colors, the outline: all identical, because none of it lives in the framework layer.&lt;/p&gt;

&lt;p&gt;And it stays small. Domternal's own code is about 44 KB gzipped (around 117 KB with ProseMirror itself), and because each feature is a separate extension your bundler strips whatever you don't import. Want the slash menu but not toggles? Leave &lt;code&gt;Details&lt;/code&gt; out and it's gone from the bundle entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a headless, framework-agnostic block editor
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1xqatac8w9hvrbx6j4ar.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1xqatac8w9hvrbx6j4ar.png" alt="Angular, React, Vue, and Vanilla JS logos all feeding into the same Domternal block editor" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three reasons, the same three that started the project. &lt;strong&gt;It's composable:&lt;/strong&gt; every piece is its own extension, so you assemble the exact editor you want and your bundler drops the rest. &lt;strong&gt;It's framework-agnostic:&lt;/strong&gt; the block layer is plain ProseMirror, so it behaves the same in React, Angular, Vue, and Vanilla, with no "the drag handle only works in React" footnote. &lt;strong&gt;It's MIT:&lt;/strong&gt; block editing, drag-to-reorder, the outline, the color picker, and toggles are all free, with nothing behind a paid tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Notion Mode guide:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/guides/notion-mode/" rel="noopener noreferrer"&gt;domternal.dev/v1/guides/notion-mode&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;domternal.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting started:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/getting-started/" rel="noopener noreferrer"&gt;domternal.dev/v1/getting-started&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Packages &amp;amp; bundle size:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/packages/" rel="noopener noreferrer"&gt;domternal.dev/v1/packages&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;github.com/domternal/domternal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The live editor on the homepage runs the full block setup, slash menu and handles included, so you can grab a paragraph and drag it around right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The block layer is broad and tested, but there's always more Notion to chase. The big one is &lt;em&gt;arbitrary&lt;/em&gt; nesting. Today any block can be reordered, and list, to-do, and toggle blocks hold children, but you can't yet nest an arbitrary paragraph under another paragraph the way Notion does. The plan is to ship that as an opt-in package so the default editor keeps emitting clean semantic HTML and stays lightweight, and you only take on nesting's complexity when you actually want it. Columns and a synced-block style reference are the other obvious targets.&lt;/p&gt;

&lt;p&gt;If you put this in front of a real document and something feels even slightly off compared to the editor you're used to, that's exactly the feedback I want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the one block interaction you can't live without?&lt;/strong&gt; Tell me in the comments and I'll see if it's already an extension away.&lt;/p&gt;

</description>
      <category>notion</category>
      <category>showdev</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Making Domternal Accessible. What WCAG 2.1 AA Actually Looks Like in a Rich Text Editor.</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Tue, 14 Apr 2026 16:03:25 +0000</pubDate>
      <link>https://dev.to/thomasnowheredev/making-domternal-accessible-what-wcag-21-aa-actually-looks-like-in-a-rich-text-editor-4nii</link>
      <guid>https://dev.to/thomasnowheredev/making-domternal-accessible-what-wcag-21-aa-actually-looks-like-in-a-rich-text-editor-4nii</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Updated September 5, 2026:&lt;/strong&gt; this article documents the accessibility work shipped in Domternal v0.5.0. Domternal is now framework-agnostic, with first-party Angular, React, Vue 3 and Vanilla integration packages. The historical implementation and its 159 automated checks remain unchanged; current product facts and accessibility limitations have been clarified.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Rich text editors combine a &lt;code&gt;contenteditable&lt;/code&gt; surface with toolbars, floating menus, dropdown panels, emoji pickers, table controls, autocomplete suggestions and popovers. Each part needs intentional semantics, keyboard behavior and focus management. In Domternal, that work crosses the editing core, framework integrations and theme rather than living in one component.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;Domternal&lt;/a&gt;, a ProseMirror-based rich text editor toolkit with first-party Angular, React, Vue 3 and Vanilla integration packages. ProseMirror supplied part of the editor's basic keyboard behavior, but it did not automatically make the surrounding UI accessible. Before this pass, several menus, pickers and table controls lacked focus indicators, complete semantics or intentional keyboard paths. The &lt;a href="https://domternal.dev/v1/nodes/emoji/" rel="noopener noreferrer"&gt;emoji picker&lt;/a&gt; did not yet provide arrow-key grid handling.&lt;/p&gt;

&lt;p&gt;This article describes the accessibility work included in Domternal v0.5.0. The work crossed &lt;strong&gt;8 packages&lt;/strong&gt;, and its validation set contained &lt;strong&gt;159 automated E2E checks&lt;/strong&gt;: 83 Angular and 76 React. Those checks verify DOM attributes, focus, keyboard and CSS behavior; they are not a manual screen-reader audit or a claim of formal WCAG conformance.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Editor semantics
&lt;/h2&gt;

&lt;p&gt;ProseMirror renders a &lt;code&gt;contenteditable&lt;/code&gt; div. By default, it has no ARIA attributes, so the accessibility tree exposes only a generic editable surface with no product-specific name, multiline state or read-only state.&lt;/p&gt;

&lt;p&gt;I added four attributes to the editor element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-multiline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ariaLabel&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rich text editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...((&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editable&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-readonly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This exposes a named multiline textbox in the accessibility tree. Exact speech and interaction behavior vary by browser, operating system, screen reader and user settings.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;aria-readonly&lt;/code&gt; attribute is dynamic. When someone calls &lt;code&gt;editor.setEditable(false)&lt;/code&gt;, the attribute appears; when the editor becomes editable again, it is removed. This makes the state available to assistive technology without recreating the editor element.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgazhfo94mcqdpydt4ttt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgazhfo94mcqdpydt4ttt.png" alt="Chrome DevTools Accessibility tree showing the editor as a multiline textbox named Rich text editor" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Focus indicators: &lt;code&gt;:focus-visible&lt;/code&gt;, not &lt;code&gt;:focus&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is a common mistake. Many editors use &lt;code&gt;:focus&lt;/code&gt; for styling, which shows focus rings on mouse clicks too. You click a toolbar button and it gets an ugly blue ring. That's not helpful, it's visual noise.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;:focus-visible&lt;/code&gt; follows browser heuristics and matches when the user agent determines that a focus indicator should be shown. For ordinary toolbar buttons, that typically preserves a visible ring during keyboard navigation without leaving the same ring after a pointer click.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;:focus-visible&lt;/code&gt; indicators to &lt;strong&gt;16 interactive element types&lt;/strong&gt; across 9 SCSS files. The standard pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;:focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;dm-accent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;outline-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This covers toolbar buttons, dropdown items, emoji picker tabs, emoji swatches, suggestion items, table handles, table cell toolbar buttons, table dropdown buttons, table alignment items, image popover buttons, link popover buttons, details toggle buttons, and mention suggestion items.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkw3x1dd12px2wxc1v2tv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkw3x1dd12px2wxc1v2tv.png" alt="Keyboard focus shows a visible ring on the Bold button while a mouse click does not" width="799" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One exception: color swatches are circular, so a rectangular &lt;code&gt;outline&lt;/code&gt; doesn't follow their shape. I used &lt;code&gt;box-shadow&lt;/code&gt; instead to create a double ring that matches the swatch border radius:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.dm-color-swatch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;:focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;dm-toolbar-bg&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;#f8f9fa&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;dm-accent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inner ring matches the toolbar background so it doesn't bleed into the swatch color, and the outer ring is the accent color.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0bqfwt8efv1zwcwagnq1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0bqfwt8efv1zwcwagnq1.png" alt="Color palette with a circular focus ring on one swatch" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Toolbar keyboard navigation
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://domternal.dev/v1/guides/toolbar/" rel="noopener noreferrer"&gt;toolbar&lt;/a&gt; without keyboard navigation is just a row of buttons you can Tab through one by one. That's technically keyboard-accessible, but it's a terrible experience when you have 20+ buttons. You'd press Tab 15 times just to reach "Insert Table".&lt;/p&gt;

&lt;p&gt;The WAI-ARIA toolbar pattern solves this: one Tab stop for the entire toolbar, then Arrow keys to navigate between buttons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Roving tabindex
&lt;/h3&gt;

&lt;p&gt;The toolbar uses the &lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/" rel="noopener noreferrer"&gt;roving tabindex pattern&lt;/a&gt;. Only the currently focused button has &lt;code&gt;tabindex="0"&lt;/code&gt;. All others have &lt;code&gt;tabindex="-1"&lt;/code&gt;. Pressing Tab moves focus out of the toolbar entirely. ArrowLeft/ArrowRight move between buttons. Home and End jump to the first and last button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0eoan8pu2fssbhg82r3a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0eoan8pu2fssbhg82r3a.gif" alt="Focus ring moving between toolbar buttons with ArrowRight" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Dropdown navigation
&lt;/h3&gt;

&lt;p&gt;When a toolbar button opens a dropdown, such as heading level or font size, the menu pattern takes over. The dropdown container gets &lt;code&gt;role="menu"&lt;/code&gt;, and each item gets &lt;code&gt;role="menuitem"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;ArrowDown from the trigger opens the dropdown and focuses the first item. ArrowDown/ArrowUp inside the dropdown cycles through items with wrapping, meaning ArrowDown on the last item goes back to the first. Escape closes the dropdown and returns focus to the trigger button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpjgwbkjucfcn2o21966v.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpjgwbkjucfcn2o21966v.gif" alt="ArrowDown opens the heading dropdown and cycles through items while Escape closes it" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Bubble menu ARIA
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://domternal.dev/v1/extensions/bubble-menu/" rel="noopener noreferrer"&gt;bubble menu&lt;/a&gt; is the floating toolbar that appears when you select text. Without ARIA, its controls lack the group and state semantics assistive technology needs to identify the menu as a formatting toolbar.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;role="toolbar"&lt;/code&gt; and &lt;code&gt;aria-label="Text formatting"&lt;/code&gt; on the container. Each toggle button, such as bold, italic and underline, gets &lt;code&gt;aria-pressed&lt;/code&gt; synced with the editor state, allowing assistive technology to expose whether the control is active. Separators between button groups use &lt;code&gt;role="separator"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrbem1awruopa7q2xzlv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrbem1awruopa7q2xzlv.png" alt="Bubble menu with the Bold button active on selected bold text" width="800" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both Angular and React implementations keep this in sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// React&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;aria-pressed&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

// Angular
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="err"&gt;[&lt;/span&gt;&lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;aria-pressed&lt;/span&gt;&lt;span class="err"&gt;]&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"isItemActive(item)"&lt;/span&gt; &lt;span class="err"&gt;[&lt;/span&gt;&lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="err"&gt;]&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"item.label"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Emoji picker: 2D grid navigation
&lt;/h2&gt;

&lt;p&gt;The emoji picker is a grid of hundreds of small buttons. Moving through that many controls one Tab press at a time is impractical, so the picker needs an intentional two-dimensional keyboard path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tab semantics and search
&lt;/h3&gt;

&lt;p&gt;The category selector at the top exposes &lt;code&gt;role="tablist"&lt;/code&gt;, &lt;code&gt;role="tab"&lt;/code&gt; and &lt;code&gt;aria-selected&lt;/code&gt; on its category buttons. The search input has &lt;code&gt;aria-label="Search emoji"&lt;/code&gt; because the placeholder alone is not a persistent accessible name. These roles and states were part of the v0.5.0 foundation; this pass did not claim the complete WAI-ARIA tabs keyboard interaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grid keyboard navigation
&lt;/h3&gt;

&lt;p&gt;Every emoji swatch has &lt;code&gt;tabindex="-1"&lt;/code&gt;, removing it from the Tab order. Once focus is placed on a swatch, the v0.5.0 handler moves through the 8-column grid as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ArrowRight/ArrowLeft&lt;/strong&gt; move horizontally, one emoji at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ArrowDown/ArrowUp&lt;/strong&gt; jump by 8 to move vertically, one row at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enter or Space&lt;/strong&gt; selects the focused emoji&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Navigation is bounded, not cyclic. ArrowLeft on the first emoji stays there. ArrowDown on the last row stays on the last row. The automated checks exercise this handler after placing focus on a swatch. They do not establish a complete keyboard handoff from the search or category controls into the grid, so this section should not be read as a claim of complete picker keyboard usability.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwlnnqw7ryt4ckmv1j1t9.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwlnnqw7ryt4ckmv1j1t9.gif" alt="Arrow keys navigating the emoji grid after focus is placed on a swatch" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Table controls
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://domternal.dev/v1/nodes/table/" rel="noopener noreferrer"&gt;Tables&lt;/a&gt; have the most complex UI in the editor: a cell toolbar with formatting buttons, row/column dropdowns with insert/delete/merge actions, a color palette for cell backgrounds, and an alignment picker. Each one needed the correct ARIA pattern.&lt;/p&gt;

&lt;p&gt;The cell toolbar gets &lt;code&gt;role="toolbar"&lt;/code&gt; with &lt;code&gt;aria-label="Cell formatting"&lt;/code&gt;. Row/column dropdowns use &lt;code&gt;role="menu"&lt;/code&gt; with contextual labels such as "Row options" and "Column options". Every action button inside is &lt;code&gt;role="menuitem"&lt;/code&gt;. The color palette and alignment picker follow the same pattern. Separators between horizontal and vertical alignment options use &lt;code&gt;role="separator"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6qqd2tkn11zz1unw3en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6qqd2tkn11zz1unw3en.png" alt="Table with the column dropdown showing Insert and Delete options" width="800" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Input labels
&lt;/h2&gt;

&lt;p&gt;The text inputs covered by this pass have an explicit &lt;code&gt;aria-label&lt;/code&gt;, so their purpose is available in the accessibility tree instead of relying on placeholder text.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;State exposed to assistive technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://domternal.dev/v1/extensions/link-popover/" rel="noopener noreferrer"&gt;Link popover&lt;/a&gt; URL input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"URL"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Named URL text input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image popover URL input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Image URL"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Named image URL text input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emoji picker search&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Search emoji"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Named search text input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task item checkbox&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Task status"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Named checkbox with checked state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5mjwmd5h454n75i79wp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5mjwmd5h454n75i79wp.png" alt="Link popover with a named URL input" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The floating menu also gets a default &lt;code&gt;role="toolbar"&lt;/code&gt; and &lt;code&gt;aria-label="Floating menu"&lt;/code&gt; if the user hasn't set one.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Autocomplete suggestions
&lt;/h2&gt;

&lt;p&gt;Both the emoji &lt;code&gt;:shortcode:&lt;/code&gt; autocomplete and the &lt;code&gt;@mention&lt;/code&gt; autocomplete render suggestion dropdowns. These use the &lt;code&gt;listbox&lt;/code&gt; pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;listbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Emoji suggestions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Each suggestion item&lt;/span&gt;
&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;option&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-selected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;selectedIndex&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aria-selected&lt;/code&gt; tracks the currently highlighted item as you navigate with arrow keys, making the active option available to assistive technology.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifs3ktvzifh6jdeqk0ms.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifs3ktvzifh6jdeqk0ms.png" alt="Emoji suggestion dropdown filtering results while typing a thumbs-up shortcode" width="746" height="772"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Reduced motion
&lt;/h2&gt;

&lt;p&gt;Some users have vestibular disorders or motion sensitivity. The &lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query lets them opt out of animations and transitions at the OS level.&lt;/p&gt;

&lt;p&gt;I disabled the animations and transitions covered by this pass when this preference is set. This includes fade-in animations on floating elements, such as the emoji picker, suggestion dropdowns, toolbar panels and table controls, the gapcursor blink animation, and the theme's covered hover and focus transitions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-emoji-picker&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-emoji-suggestion&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-toolbar-dropdown-panel&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-table-controls-dropdown&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-table-cell-toolbar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.dm-toolbar-button&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-emoji-swatch&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-color-swatch&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="o"&gt;/*&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="nt"&gt;and&lt;/span&gt; &lt;span class="nt"&gt;20&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nt"&gt;more&lt;/span&gt; &lt;span class="nt"&gt;selectors&lt;/span&gt; &lt;span class="o"&gt;*/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A CSS cascade lesson I learned the hard way:&lt;/strong&gt; I initially placed this block in &lt;code&gt;_base.scss&lt;/code&gt;, which is imported first in the stylesheet. But the toolbar's &lt;code&gt;transition: background-color 0.15s&lt;/code&gt; in &lt;code&gt;_toolbar.scss&lt;/code&gt;, imported later, overrode the &lt;code&gt;transition: none&lt;/code&gt;. The fix was moving the entire &lt;code&gt;prefers-reduced-motion&lt;/code&gt; block to the very end of &lt;code&gt;index.scss&lt;/code&gt;, after all other imports, so it wins the cascade.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Selection collapse on blur
&lt;/h2&gt;

&lt;p&gt;This is an accessibility and UX fix that's easy to overlook. When you select text in the editor and click outside, the browser's native selection highlight stays visible. This creates "ghost selections": the toolbar shows Bold and Italic as enabled for text that's no longer actively selected. If a user clicks Bold now, it would format text they didn't intend to format.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SelectionDecoration&lt;/code&gt; extension, included in &lt;code&gt;StarterKit&lt;/code&gt; and optional with &lt;code&gt;selectionDecoration: false&lt;/code&gt;, collapses the ProseMirror selection to a cursor on blur. This keeps the visible selection and toolbar state aligned with editor focus. Assistive-technology behavior varies by browser and screen reader and is not established by these automated checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;Accessibility behavior can regress during a refactor. I wrote &lt;strong&gt;159 E2E checks&lt;/strong&gt;: 83 Angular and 76 React, for the implemented DOM, focus, keyboard and CSS behavior. Each category runs against both framework demo apps via Playwright. These automated checks do not replace manual testing with assistive technologies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editor ARIA:&lt;/strong&gt; &lt;code&gt;role="textbox"&lt;/code&gt;, &lt;code&gt;aria-multiline&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, &lt;code&gt;contenteditable&lt;/code&gt;, absence of &lt;code&gt;aria-readonly&lt;/code&gt; when editable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic &lt;code&gt;aria-readonly&lt;/code&gt;:&lt;/strong&gt; attribute appears when &lt;code&gt;setEditable(false)&lt;/code&gt; is called, disappears when set back to &lt;code&gt;true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bubble menu ARIA:&lt;/strong&gt; &lt;code&gt;role="toolbar"&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, &lt;code&gt;aria-pressed&lt;/code&gt; on toggle buttons synced with bold/italic state, &lt;code&gt;role="separator"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Toolbar dropdown keyboard navigation:&lt;/strong&gt; ArrowDown opens dropdown and focuses first item, ArrowDown/ArrowUp cycle through items, ArrowUp wraps from first to last, Escape closes and returns focus to trigger, &lt;code&gt;role="menu"&lt;/code&gt; on panel, &lt;code&gt;role="menuitem"&lt;/code&gt; and &lt;code&gt;tabindex="-1"&lt;/code&gt; on items&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji picker ARIA:&lt;/strong&gt; &lt;code&gt;aria-label&lt;/code&gt; on search input, &lt;code&gt;role="tablist"&lt;/code&gt; on container, &lt;code&gt;role="tab"&lt;/code&gt; and &lt;code&gt;aria-selected&lt;/code&gt; on category buttons, &lt;code&gt;aria-label&lt;/code&gt; on each swatch, &lt;code&gt;tabindex="-1"&lt;/code&gt; on all swatches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji grid keyboard navigation:&lt;/strong&gt; ArrowRight/Left/Down/Up movement, boundary behavior without wrapping, Enter and Space to select, same behavior in search results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task checkbox:&lt;/strong&gt; &lt;code&gt;aria-label="Task status"&lt;/code&gt; on both checked and unchecked states&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link popover:&lt;/strong&gt; &lt;code&gt;aria-label&lt;/code&gt; on URL input, Apply and Remove buttons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image popover:&lt;/strong&gt; &lt;code&gt;aria-label&lt;/code&gt; on URL input, Insert and Browse buttons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table cell toolbar:&lt;/strong&gt; &lt;code&gt;role="toolbar"&lt;/code&gt; with &lt;code&gt;aria-label&lt;/code&gt; when visible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji suggestion:&lt;/strong&gt; &lt;code&gt;role="listbox"&lt;/code&gt; and &lt;code&gt;aria-label&lt;/code&gt; on container, &lt;code&gt;role="option"&lt;/code&gt; on items&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mention suggestion:&lt;/strong&gt; &lt;code&gt;role="listbox"&lt;/code&gt; and &lt;code&gt;aria-label&lt;/code&gt; on container&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;:focus-visible&lt;/code&gt; indicators:&lt;/strong&gt; keyboard focus shows outline, mouse click does not&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prefers-reduced-motion&lt;/code&gt;:&lt;/strong&gt; animations disabled with &lt;code&gt;animationDuration: 0s&lt;/code&gt;, transitions disabled with &lt;code&gt;transitionDuration: 0s&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;prefers-reduced-motion&lt;/code&gt; tests use &lt;code&gt;page.emulateMedia({ reducedMotion: 'reduce' })&lt;/code&gt; to simulate the OS preference. The focus-visible tests verify both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolbar button shows outline on keyboard focus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.dm-toolbar-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;outlineStyle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolbar button does not show outline on mouse click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.dm-toolbar-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;outlineStyle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffp620e1ic328mzbaa4cn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffp620e1ic328mzbaa4cn.png" alt="Playwright accessibility test results from the v0.5.0 implementation" width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I skipped and why
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skip navigation link&lt;/td&gt;
&lt;td&gt;The editor is an embedded component, not a page. Skip links are for page-level navigation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@media (forced-colors)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Outside this implementation pass.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image alt text enforcement&lt;/td&gt;
&lt;td&gt;Outside this implementation pass. The schema supports the &lt;code&gt;alt&lt;/code&gt; attribute; the host application decides whether its authoring policy requires one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;aria-live&lt;/code&gt; regions&lt;/td&gt;
&lt;td&gt;Outside this implementation pass. The editor provides data such as character and word counts, and the consuming app can expose selected updates through &lt;code&gt;role="status"&lt;/code&gt; where appropriate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screen reader testing&lt;/td&gt;
&lt;td&gt;Not part of these automated checks. VoiceOver, NVDA and other assistive technologies require a separate manual testing pass.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Before v0.5.0, keyboard users could type in the content area, but several surrounding toolbars, menus, dropdowns, pickers and table controls lacked the intentional keyboard paths added in this pass.&lt;/p&gt;

&lt;p&gt;After v0.5.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The interactive elements covered by this pass have a visible focus indicator on keyboard navigation, not on mouse click&lt;/li&gt;
&lt;li&gt;The toolbar and dropdown paths are covered, and the emoji handler is verified once focus is placed on a swatch&lt;/li&gt;
&lt;li&gt;The covered inputs, buttons and toggles expose accessible names&lt;/li&gt;
&lt;li&gt;The covered dropdowns use the implemented WAI-ARIA menu pattern&lt;/li&gt;
&lt;li&gt;The covered suggestion lists use the implemented listbox pattern&lt;/li&gt;
&lt;li&gt;The covered theme surfaces suppress their documented animations and transitions when reduced motion is requested&lt;/li&gt;
&lt;li&gt;The editor's read-only state is communicated to assistive technology&lt;/li&gt;
&lt;li&gt;159 E2E checks verify the listed automated behavior across Angular and React; they do not establish screen-reader interoperability or WCAG conformance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Accessibility is not a one-time checkbox. It has to be part of how an editor is built, tested and reviewed.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Domternal Free&lt;/strong&gt; is a framework-agnostic, MIT-licensed ProseMirror rich text editor with first-party Angular, React, Vue 3 and Vanilla integration packages. The current release includes 17 MIT packages, with 70+ extensions across core and 10 extension packages. For the maintained package inventory, command and test totals, and bundle measurements, see the &lt;a href="https://domternal.dev/v1/packages/" rel="noopener noreferrer"&gt;package guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;github.com/domternal/domternal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;domternal.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live playground:&lt;/strong&gt; &lt;a href="https://domternal.dev/playground/" rel="noopener noreferrer"&gt;domternal.dev/playground&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessible authored content:&lt;/strong&gt; &lt;a href="https://domternal.dev/blog/making-editor-content-accessible-alt-text-and-aria/" rel="noopener noreferrer"&gt;Alt text and ARIA in editor content&lt;/a&gt;&lt;/p&gt;

</description>
      <category>angular</category>
      <category>react</category>
      <category>a11y</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Built a First-Class Rich Text Editor for Angular</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Mon, 23 Mar 2026 21:10:36 +0000</pubDate>
      <link>https://dev.to/thomasnowheredev/angular-deserves-better-than-react-editor-wrappers-so-i-built-one-2amn</link>
      <guid>https://dev.to/thomasnowheredev/angular-deserves-better-than-react-editor-wrappers-so-i-built-one-2amn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Updated September 5, 2026:&lt;/strong&gt; this article began as a Domternal v0.2.0 build log. Domternal is now framework-agnostic, with first-party Angular, React, Vue 3 and Vanilla JavaScript integrations. The Angular motivation remains the same, while obsolete package counts, bundle figures and roadmap claims have been updated. The &lt;a href="https://domternal.dev/v1/packages/" rel="noopener noreferrer"&gt;v1 package table&lt;/a&gt; and &lt;a href="https://domternal.dev/v1/guides/angular/" rel="noopener noreferrer"&gt;Angular guide&lt;/a&gt; are the maintained sources for current details.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you've ever tried to add a rich text editor to an Angular app, you know how it goes.&lt;/p&gt;

&lt;p&gt;Many options can put an editor on the page quickly. The harder part begins when the editor has to behave like a real Angular dependency: predictable &lt;code&gt;OnPush&lt;/code&gt; updates, standalone components, reactive forms, typed events, theme customization and UI that stays in sync with the editing state.&lt;/p&gt;

&lt;p&gt;I kept reaching the same point in Angular projects: the editing engine worked, but the surrounding integration still needed application-specific glue. After years of rebuilding that layer, I decided to build the editor integration I wanted to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Domternal&lt;/strong&gt; is a framework-agnostic rich text editor toolkit built on ProseMirror. Its core owns the document model, extensions and commands without depending on a component framework. The Angular package adds first-party components built with Signals, &lt;code&gt;OnPush&lt;/code&gt;, standalone APIs and reactive forms support.&lt;/p&gt;

&lt;p&gt;Domternal now ships as 17 MIT-licensed npm packages under the &lt;code&gt;@domternal&lt;/code&gt; scope. First-party Angular, React, Vue 3 and Vanilla JavaScript integrations all use the same core. Angular is a first-class integration, not the only framework Domternal supports.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Angular experience I wanted
&lt;/h2&gt;

&lt;p&gt;The problem was not that every alternative was bad. Different editors optimize for different constraints. Some begin with a framework-neutral JavaScript engine and add Angular bindings. Others rely on community-maintained wrappers, while commercial suites package features and licensing in different ways.&lt;/p&gt;

&lt;p&gt;I needed a particular combination:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;standalone Angular components with Signals and &lt;code&gt;OnPush&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ControlValueAccessor&lt;/code&gt; support for &lt;code&gt;ngModel&lt;/code&gt; and reactive forms&lt;/li&gt;
&lt;li&gt;extension-driven toolbars and menus that track editor state automatically&lt;/li&gt;
&lt;li&gt;normal theme customization without application-specific editor overrides&lt;/li&gt;
&lt;li&gt;a typed ProseMirror foundation that also works outside Angular&lt;/li&gt;
&lt;li&gt;a complete Free editor that remains independently available under MIT&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That combination became Domternal. The Free edition is a complete MIT-licensed editor, not a time-limited trial. Domternal Pro is a separate commercial, self-hosted layer for collaboration, comments, version history, columns, AI-assisted workflows and document export.&lt;/p&gt;

&lt;p&gt;If you are evaluating alternatives today, the maintained &lt;a href="https://domternal.dev/compare/tiptap-vs-domternal/" rel="noopener noreferrer"&gt;Tiptap vs Domternal comparison&lt;/a&gt; and &lt;a href="https://domternal.dev/compare/ckeditor-vs-domternal/" rel="noopener noreferrer"&gt;CKEditor vs Domternal comparison&lt;/a&gt; separate editor capabilities, framework integrations, hosting models and licensing without treating unlike plans as equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes Domternal different
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;6 Angular components&lt;/strong&gt;: editor, toolbar, bubble menu, floating menu, emoji picker and Notion color picker. All use Signals, OnPush and standalone components. No NgModules are required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables are free.&lt;/strong&gt; Cell merge/split, column resize, cell styling and the cell toolbar are included in Domternal Free, with 18 table commands published under MIT.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The toolbar is extension-driven.&lt;/strong&gt; Extensions that contribute toolbar items are discovered automatically, so their controls appear with active and disabled states without repeated button wiring in the application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;70+ extensions across core and 10 extension packages&lt;/strong&gt;: headings, lists, code blocks with syntax highlighting, images, emoji, details, text color, font size, math, Markdown, block controls and more. Core, the four integrations, the theme and those extension packages make 17 current MIT packages in total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JavaScript exports are tree-shakeable.&lt;/strong&gt; The full Domternal-owned core entry is about 51 KiB minified and gzipped before ProseMirror and helper dependencies, while the current complete core bundle is about 132 KiB with those runtime dependencies. Additional extensions such as tables, images and emoji are separate packages. Actual output depends on your imports, configuration and bundler; the theme is delivered as a complete CSS stylesheet. See the maintained &lt;a href="https://domternal.dev/v1/packages/" rel="noopener noreferrer"&gt;bundle size breakdown&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;17,000+ automated test executions&lt;/strong&gt; across unit coverage and a browser matrix for Angular, React, Vue and Vanilla. The exact current breakdown belongs in the repository and maintained documentation rather than in this historical article.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Light and dark themes with 150+ CSS custom properties.&lt;/strong&gt; Applications can change color, spacing and typography without forking the editor styles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript APIs.&lt;/strong&gt; Public extensions and commands expose typed APIs and command inference without making this article a permanent claim about every internal implementation detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema conflict detection&lt;/strong&gt;: if you accidentally register two extensions with the same name, which can happen when using StarterKit alongside individual extensions, Domternal throws a clear error instead of silently letting the last one win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free and Pro today
&lt;/h2&gt;

&lt;p&gt;Everything described above belongs to Domternal Free unless explicitly stated otherwise. All 17 currently published packages under the &lt;code&gt;@domternal&lt;/code&gt; scope are MIT licensed.&lt;/p&gt;

&lt;p&gt;Domternal Pro is a separate, optional set of commercially licensed packages under the &lt;code&gt;@domternal-pro&lt;/code&gt; scope. It adds real-time collaboration, comments, version history, columns, AI-assisted workflows and local Word/PDF export. The Pro packages run with infrastructure selected by the application and do not require a Domternal-hosted editing service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick setup
&lt;/h2&gt;

&lt;p&gt;Here's a minimal Angular example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @domternal/core @domternal/angular @domternal/theme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;DomternalEditorComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;DomternalToolbarComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/angular&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StarterKit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;DomternalEditorComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DomternalToolbarComponent&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
    @if (editor(); as ed) {
      &amp;lt;domternal-toolbar [editor]="ed" /&amp;gt;
    }
    &amp;lt;domternal-editor
      [extensions]="extensions"
      [content]="content"
      (editorCreated)="editor.set($event)"
    /&amp;gt;
  `&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditorComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Editor&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;extensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;StarterKit&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;p&amp;gt;Hello world&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the theme import to your styles and you're done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;@use&lt;/span&gt; &lt;span class="s1"&gt;'@domternal/theme'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;See the &lt;a href="https://domternal.dev/playground/" rel="noopener noreferrer"&gt;full Angular example&lt;/a&gt; with all extensions, toolbar and bubble menu, or read the &lt;a href="https://domternal.dev/v1/getting-started/" rel="noopener noreferrer"&gt;Getting Started guide&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  No framework? No problem.
&lt;/h3&gt;

&lt;p&gt;The core is fully headless and works without any framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @domternal/core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Paragraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Italic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Underline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Paragraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Italic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Underline&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;p&amp;gt;Hello &amp;lt;strong&amp;gt;Bold&amp;lt;/strong&amp;gt;, &amp;lt;em&amp;gt;Italic&amp;lt;/em&amp;gt; and &amp;lt;u&amp;gt;Underline&amp;lt;/u&amp;gt;!&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import the extensions you need for direct control, or use &lt;code&gt;StarterKit&lt;/code&gt; for a batteries-included setup with headings, lists, code blocks, history and more. Your final JavaScript output depends on the imports and bundler configuration you choose.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;See the &lt;a href="https://domternal.dev/playground/" rel="noopener noreferrer"&gt;full Vanilla TS example&lt;/a&gt; with toolbar, bubble menu and all extensions, or read the &lt;a href="https://domternal.dev/v1/getting-started/" rel="noopener noreferrer"&gt;Getting Started guide&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Domternal Free today
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Domternal Free&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework integrations&lt;/td&gt;
&lt;td&gt;Angular, React, Vue 3 and Vanilla JavaScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Angular components&lt;/td&gt;
&lt;td&gt;6: editor, toolbar, bubble menu, floating menu, emoji picker and Notion color picker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Current MIT packages&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensions&lt;/td&gt;
&lt;td&gt;70+ across core and 10 extension packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nodes&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marks&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commands&lt;/td&gt;
&lt;td&gt;130+ chainable commands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;17,000+ automated test executions across maintained suites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domternal-owned core surface&lt;/td&gt;
&lt;td&gt;~51 KiB minified and gzipped before dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full core with current dependencies&lt;/td&gt;
&lt;td&gt;~132 KiB minified and gzipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tree-shaking&lt;/td&gt;
&lt;td&gt;Actual output depends on imports, configuration and bundler behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;Typed public extension and command APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table commands&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try it now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;domternal.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Angular guide:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/guides/angular/" rel="noopener noreferrer"&gt;domternal.dev/v1/guides/angular&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting started:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/getting-started/" rel="noopener noreferrer"&gt;domternal.dev/v1/getting-started&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Packages and bundle size:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/packages/" rel="noopener noreferrer"&gt;domternal.dev/v1/packages&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;github.com/domternal/domternal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live playground:&lt;/strong&gt; &lt;a href="https://domternal.dev/playground/" rel="noopener noreferrer"&gt;domternal.dev/playground&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Since the original v0.2.0 article
&lt;/h2&gt;

&lt;p&gt;React and Vue integrations, math, drag handles, block controls and other features that were once described as future work now exist in the maintained v1 documentation. Future requests can inform product planning, but neither this historical post nor a request is a delivery commitment.&lt;/p&gt;

&lt;p&gt;The complete Free editor remains MIT licensed. The optional &lt;a href="https://domternal.dev/pro/" rel="noopener noreferrer"&gt;Domternal Pro&lt;/a&gt; packages now add collaboration, comments, version history, columns, AI-assisted workflows and Word/PDF export for teams that need those workflows while retaining control of their application infrastructure.&lt;/p&gt;

&lt;p&gt;The current release status, supported packages and installation instructions are published in the &lt;a href="https://domternal.dev/v1/" rel="noopener noreferrer"&gt;v1 documentation&lt;/a&gt;. I would still appreciate feedback on the API design, documentation or anything that could be better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's been your biggest pain point with rich text editing in Angular?&lt;/strong&gt; I'd love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>showdev</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
