@farena/fa-editor
@farena/fa-editor
A rich-text editor for Vue 3 with zero dependencies and a strict, stable HTML contract.
It is built for the case where the HTML lives in a database and has to survive being opened and
saved again, unchanged, for years. getData(setData(x)) === x is the property the whole design
is organized around: if it ever breaks, opening a form and saving it without touching anything
silently rewrites stored content.
- No dependencies at all, runtime or build. Vue is a peer dependency and that is the entire list.
contenteditableplus the Selection/Range API.document.execCommandis never used: it produces<b>,<font>andrgb(), while the contract calls for<strong>,<span>andhsl(), and it cannot see mutations made through the Range API.- WYSIWYG for real. The editable area applies no content styles of its own, so what you see
while typing is what the page renders afterwards with
v-html. - ~36 kB gzipped, CSS and icons included.
Try it in the lab → — every feature of the contract on
one page, with the serialized HTML and a v-html preview side by side, so you can check that what
you edit and what your app renders are the same thing.
Table of contents
- Requirements
- Installation
- Quick start
- API
- Mentions
- Languages
- Keyboard
- The HTML contract
- Styling
- Rendering content outside the editor
- Icons
- Known limitations
- Contributing
- License
Requirements
- Vue 3.3 or newer.
- Nothing else. No icon font, no CDN, no runtime dependency: the toolbar icons are inline
<svg>drawn by the package itself; see Icons. - A browser with
beforeinputandInputEvent.inputType: Chrome, Edge, Firefox 87+, Safari 13+.
Installation
npm i @farena/fa-editor
Quick start
Register it globally, as a plugin:
// main.js
import { createApp } from 'vue'
import FaEditor from '@farena/fa-editor'
import '@farena/fa-editor/style.css'
createApp(App).use(FaEditor).mount('#app')
use() takes an optional name, if FaEditor collides with something in your app:
app.use(FaEditor, { name: 'RichText' })
Or import it locally and skip the global registration:
<template>
<FaEditor
ref="editor"
v-model="html"
placeholder="Type something…"
:disabled="readonly"
:autocompleteOpts="['#name', '#email']"
@focus="onFocus"
@blur="onBlur"
/>
</template>
<script>
import { FaEditor } from '@farena/fa-editor'
export default {
components: { FaEditor },
data: () => ({ html: '<p>Hello</p>', readonly: false }),
methods: {
onFocus() {},
onBlur() {}
}
}
</script>
Importing the stylesheet is not optional — the editor ships no scoped styles. If you prefer to compile it yourself, so you can override variables before they are resolved, import the Sass source instead:
@use '@farena/fa-editor/scss';
Sass requires @use rules to come before any other rule, so it has to be the first line of the
file.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue |
String |
'' |
The document's HTML. Use with v-model. |
placeholder |
String |
— | Shown while the document is empty. |
disabled |
Boolean |
false |
Read-only: turns off contenteditable and the whole toolbar. |
autocompleteOpts |
Array |
null |
Mention options. Only entries starting with # are used. |
lang |
String |
'en' |
Interface language: 'en', 'es', 'pt', or a registerLang code. |
Events
| Event | When |
|---|---|
update:modelValue |
The content changed. Debounced 300 ms, leading edge included. |
focus |
The editable area gained focus. |
blur |
The editable area lost focus. |
Methods
Available through a template ref:
| Method | What it does |
|---|---|
focus() |
Focuses the editor and puts the caret at the end of the document. |
getData() |
Returns the serialized HTML immediately, without waiting for the debounce. |
Exports
Besides the component, the package entry point exposes the i18n surface:
| Export | What it does |
|---|---|
registerLang(code, dict) |
Adds a dictionary, or merges keys over one that exists. See Languages. |
getLang(code) |
A copy of a dictionary, to build a new language on top of. Unknown codes give English. |
LANGS |
Every code lang accepts right now, registrations included. |
DEFAULT_LANG |
'en', the fallback for an unknown code and for a missing key. |
The v-model cycle
Worth understanding, because it explains the one surprising behaviour.
When the value arrives from outside, the document is reloaded. When the change originates inside, it is emitted debounced and remembered, so the incoming watcher does not reload the document and make the caret jump:
watch: {
modelValue(v) {
if (v !== this.lastEmittedData) this.load(v)
}
}
On an external load, the editor re-emits the value already normalized when it differs from what it received. So this:
this.html = '<p><a href="http://a.com">Go</a></p>'
immediately becomes:
'<p><a target="_blank" rel="noopener noreferrer" href="http://a.com">Go</a></p>'
That is deliberate. Without it the parent and the editor hold two different representations of the same content, and the user's very first keystroke produces a diff that has nothing to do with what they typed.
The debounce fires on the leading edge, so the first character typed is emitted right away. Watchers on the bound value will see it without a 300 ms delay.
Mentions
Pass autocompleteOpts and typing # opens the dropdown:
<FaEditor v-model="html" :autocompleteOpts="['#name', '#surname', '#email']" />
Entries that do not start with # are ignored. The dropdown only opens when the marker starts a
block or follows a space, so abc#def types through untouched. Arrow keys move the highlight,
Enter or Tab picks, Escape closes.
A mention serializes as:
<span class="mention" data-mention="#name">#name</span>
The span stays editable. Once its text no longer matches data-mention, it stops being a
mention and is unwrapped — a mention that says something other than what it stands for would be
worse than no mention at all.
Languages
The interface ships in English, Spanish and Portuguese:
<FaEditor v-model="html" lang="es" />
It is reactive — binding it to a user setting re-renders every label, including the panels and
balloons teleported to <body>:
<FaEditor v-model="html" :lang="user.locale" />
An unrecognized code falls back to English rather than throwing, so a bad value never takes the toolbar down with it.
The language never touches the document. Tag names, class names, alignment values and colors
are fixed by the contract and identical in every language: a document written with
lang="pt" is byte-for-byte what the same document would be with lang="en". Only what the user
reads changes.
Adding a language
registerLang(code, dict) adds a dictionary from outside the package. Call it once, before the
app mounts, and the code becomes valid everywhere lang is accepted:
import { createApp } from 'vue'
import FaEditor, { registerLang } from '@farena/fa-editor'
import '@farena/fa-editor/style.css'
registerLang('fr', {
undo: 'Annuler (Ctrl+Z)',
bold: 'Gras (Ctrl+B)'
// …
})
createApp(App).use(FaEditor).mount('#app')
<FaEditor v-model="html" lang="fr" />
Missing keys fall back to English, so a partial dictionary is usable from the first line — the three toolbar labels you care about today, the table balloon whenever you get to it.
The registry is global to the module, not per instance: every <FaEditor> in the app sees the
same languages. That is the point — registering once at boot beats threading a dictionary through
every component that happens to render an editor.
Starting from a dictionary that ships
getLang(code) returns a copy of a dictionary, which is how you take one from the source
without hunting for the file. Spread it and override what you need:
import { registerLang, getLang } from '@farena/fa-editor'
registerLang('fr', {
...getLang('en'),
undo: 'Annuler (Ctrl+Z)',
redo: 'Rétablir (Ctrl+Y)',
bold: 'Gras (Ctrl+B)',
italic: 'Italique (Ctrl+I)'
})
For a whole language you probably want the full key list in front of you rather than inline. Dump it once from a console or a scratch script:
import { getLang } from '@farena/fa-editor'
console.log(JSON.stringify(getLang('en'), null, 2))
Paste the result into a file of your own, translate the values, and register it:
// src/i18n/fa-editor-fr.js
export default {
undo: 'Annuler (Ctrl+Z)',
redo: 'Rétablir (Ctrl+Y)',
alignment: 'Alignement du texte'
// … the rest of the keys the dump gave you
}
import { registerLang } from '@farena/fa-editor'
import fr from './i18n/fa-editor-fr.js'
registerLang('fr', fr)
Dumping beats transcribing a key list from the docs: it is whatever the version you installed
actually contains, so it cannot drift from it. Start from 'en' if you are
translating from scratch, or from the closest language you already have — getLang('es') is a
shorter trip to Catalan than English is.
Correcting a label
registerLang merges over the dictionary that is already there, so the same call fixes a single
label of a language that ships, without restating the other sixty-odd:
registerLang('es', {
bold: 'Negrita',
linkPlaceholder: 'https://miempresa.com'
})
Keyboard
| Keys | Action |
|---|---|
Ctrl+B / Ctrl+I / Ctrl+U |
Bold / italic / underline |
Ctrl+K |
Link |
Ctrl+Z / Ctrl+Y, Ctrl+Shift+Z |
Undo / redo |
Enter |
New block, keeping tag, alignment and indentation. At the end of a heading, a paragraph. |
Shift+Enter, Ctrl+Enter |
Soft line break (<br>) |
Enter on an empty list item |
One nesting level out, and from the top level, out of the list |
Tab / Shift+Tab inside a table |
Next / previous cell; on the last cell, adds a row |
Backspace at the start of a block |
Merges into the previous block |
Formatting shortcuts the browser provides on its own that the editor does not implement —
Ctrl+Shift+X for strikethrough being the usual one — are blocked, because they would insert
tags that are not in the contract.
The HTML contract
This is the part that matters. Everything getData() produces and everything setData() accepts
follows this table:
| Feature | Output |
|---|---|
| bold / italic | <strong> / <i> |
| underline | <u> |
| headings | <h2> <h3> <h4> — there is no h1 |
| alignment | style="text-align:center;" on the block |
| indentation | style="margin-left:40px;" on the block — 40 px per level, up to 10; a list item nests instead |
| font family | <span style="font-family:'Courier New', Courier, monospace;"> — 8 families plus default |
| font size | classes: text-tiny text-small text-big text-huge |
| font color | <span style="color:hsl(0,75%,60%);"> — a palette of 15, plus any color already in the data |
| link | <a target="_blank" rel="noopener noreferrer" href="https://github.com/farena/fa-editor/blob/HEAD/...">, in that order; default protocol http:// |
| lists | <ul><li> / <ol><li>; an item with more than one block keeps them (<li><p>a</p><p>b</p></li>) |
| nested lists | the sublist lives inside the item above it: <ul><li>One<ul><li>Nested</li></ul></li></ul>, up to 10 |
| table | <figure class="table"><table><tbody><tr><td>; the header row goes in <thead> with <th> |
| mention | <span class="mention" data-mention="#foo">#foo</span> |
| soft break | <br> |
| empty document | '' — an empty string, not <p></p> |
Normalization rules that are part of the contract just as much as the tags:
- Inline formats always serialize in the same order —
a>span>u>i>strong— no matter which order they were applied in, and inside the spancolorcomes beforefont-family. - All font attributes live in one
<span>, never in nested ones. - Runs of spaces collapse; a space that would sit against a
<br>becomes , and one at the end of a block is dropped. - An empty block is closed with
—<p> </p>, and<p>text<br> </p>for one ending in a break — so the empty line survives outside the editor. An empty list item is the exception: it is stored as<li></li>, because its marker already holds the line. - Inline
font-sizeandbackground-colorare discarded.font-familysurvives only on an exact match with one of the configured families. - Color values are compacted:
hsl(0, 75%, 60%)is stored ashsl(0,75%,60%). - Only
text-alignandmargin-leftsurvive in a block'sstyle, in that order. Anything else — a straypaddingfrom old content, say — is dropped. margin-leftis quantized to a level: a value between two steps is rounded to the nearest one, and anything that is not a positive length inpx—margin-left:0pxabove all, which is what other editors write for "not indented" — is dropped altogether.- Indenting a paragraph or a heading writes that margin. Indenting a list item nests it
instead: the item moves into a sublist of its own type inside the item above it, which is the
only structure the indent button produces. A
margin-lefton an<li>is still read and kept, so documents written by an earlier version survive untouched — outdenting the item is what clears it. The first item of a list has no item above it to nest into, so it cannot be indented.
The lab runs a round-trip over this whole table on load, so you can see the contract holding rather than take it on trust — and paste your own stored documents into it to check they survive.
Anything outside the contract is removed on load and on paste. Pasting from Word or Google Docs
goes through the same filter: <o:p>, mso-* classes, wrapper <div>s and inline styles that
are not on the list do not make it in.
<script> elements are dropped entirely, content included. Parsing happens through
DOMParser, which builds an inert document: no scripts run and no <img src> is fetched while
sanitizing.
Styling
Every variable the editor reads has a fallback, so it is fully styled with no configuration. Defining them is what blends it into your theme.
| Variable | Default | Controls |
|---|---|---|
--font-family |
system stack | Content typeface |
--font-size |
14px |
Content size |
--body-text |
hsl(0, 0%, 37%) |
Content color |
--color-primary |
hsl(218.1, 100%, 58%) |
Focus ring and link preview |
--radius |
2px |
Corner radius |
--fa-color-mention-background |
hsla(341, 100%, 30%, 0.1) |
Mention background |
--fa-color-mention-text |
hsl(341, 100%, 30%) |
Mention text |
--fa-ed-padding |
0.5em 1em |
Editable area padding |
--fa-ed-min-height |
100px |
Minimum height |
--fa-ed-height |
auto |
Fixed height, to force internal scrolling |
--fa-ed-font-size |
10px |
Base size the toolbar is dimensioned from |
--fa-ed-btn-size |
2.3em |
Toolbar button size |
--fa-ed-border |
hsl(220, 6%, 81%) |
Container, toolbar and panel borders |
--fa-ed-bg |
#fff |
Editor background |
--fa-ed-toolbar-bg |
hsl(0, 0%, 98%) |
Toolbar background |
--fa-ed-text |
hsl(0, 0%, 20%) |
Toolbar and panel text |
--fa-ed-btn-hover-bg |
hsl(0, 0%, 94.1%) |
Button hover |
--fa-ed-btn-on-bg |
hsl(212, 100%, 97.1%) |
Active button background |
--fa-ed-btn-on-text |
hsl(218.1, 100%, 58%) |
Active button text |
--fa-ed-focus-ring |
1px solid var(--color-primary) |
Focus ring |
--fa-ed-radius |
var(--radius) |
Editor and panel radius |
--fa-ed-shadow |
0 1px 2px 1px hsla(220, 6%, 10%, 0.15) |
Panel shadow |
--fa-ed-spacing, --fa-ed-spacing-small, --fa-ed-spacing-tiny |
0.6em, 0.3em, 0.18em |
Toolbar spacing |
--fa-ed-scrollbar |
hsl(220, 6%, 70%) |
Toolbar scrollbar thumb |
--fa-ed-scrollbar-size |
0.5em |
Toolbar scrollbar thickness |
Set them wherever it suits you. :root is the one place that reaches everything, including the
panels and balloons, which are teleported to <body> and therefore inherit nothing from the
editor:
:root {
--color-primary: #6b46c1;
--radius: 6px;
--fa-ed-toolbar-bg: #faf5ff;
}
For a single instance, set them on a wrapper — with the caveat that teleported panels will keep the global values:
.my-form .fa-editor {
--fa-ed-min-height: 300px;
}
A fixed height with internal scrolling:
.my-form .fa-editor__content {
--fa-ed-height: 240px;
}
Class prefixes are .fa-editor__* for everything inside the component and .fa-editor-* for
what is teleported to <body>.
Rendering content outside the editor
The stylesheet also carries the styles for the generated content, deliberately unscoped:
.text-tiny,.text-small,.text-big,.text-huge.mentionfigure.tableand everything inside it
So wherever you render a stored document with v-html, import the stylesheet there too and it
will look exactly as it did while being edited. Scoping these to the editor is what makes a
document change appearance the moment it leaves it.
One collision to be aware of: the contract stores tables as <figure class="table">, and
.table is also Bootstrap's class. The rules here target figure.table precisely so they win on
specificity. If you use another framework that styles a bare .table, check that it does not
outrank them.
Icons
The toolbar draws its own icons. There is no icon font to load and no CDN to reach: every icon
is an inline <svg> built from a viewBox and a single path, both shipped inside the bundle:
<svg class="fa-editor__icon" viewBox="0 0 384 512" aria-hidden="true" focusable="false">
<path d="M0 64C0 46.3 …" />
</svg>
Consequences worth knowing:
- They follow the text.
fill: currentColorandheight: 1emmean an icon takes the color and the size of the button around it, exactly as a font glyph did.--fa-ed-font-sizestill scales the whole toolbar, icons included. - They cost about 3 kB gzipped, all 27 of them, and travel inside the JS bundle. No extra request, and nothing to break when a CDN is down or a Content-Security-Policy forbids it.
- They are decorative. Every button already carries
titleandaria-label, so the<svg>isaria-hidden.
Known limitations
Honest list of what this editor does not do.
- Three languages built in (English, Spanish, Portuguese). Anything else means writing a
dictionary and handing it to
registerLang; see Adding a language. - No rectangular cell selection. You cannot drag across several cells to format them together. Merging is done through the table balloon, cell by cell.
- Undo is coalesced by typing burst, not by operation, so one
Ctrl+Zcan undo a whole sentence. - No keyboard navigation of the toolbar. Buttons carry
titleandaria-label, but there is no roving tabindex. - No image upload. An
<figure class="image">already present in a document is preserved on load, but there is no way to insert one. - External drag and drop is blocked. Dropping content from outside is cancelled rather than sanitized.
Contributing
The repo layout, the design rationale, how to run the lab and the release flow live in CONTRIBUTING.md.
License
MIT.
The icon artwork is Font Awesome Free 6 (solid), used under
CC BY 4.0. Only the paths listed in
src/core/icons.js are included; see Icons.
Related Packages
Notion-like block editor for Vue 3 — supports a sticky format toolbar and/or a f...
Vue 3 WYSIWYG-редактор без сторонних editor-движков. Собственный contenteditable...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 1.3.0 | ^3.3.0 | MIT | |
| 1.2.0 | ^3.3.0 | MIT | |
| 1.1.0 | ^3.3.0 | MIT | |
| 1.0.0 | ^3.3.0 | MIT |