@toantranmei/editor
v0.4.1
Published
Notion-style block editor for Vue 3 and Nuxt, built on Tiptap.
Maintainers
Readme
@toantranmei/editor
Notion-style block editor for Vue 3 and Nuxt, built on Tiptap.
- Slash menu (
/) with recent blocks and color commands, drag handle, block menu with search - Notion-style block selection and shortcuts, drag & drop with columns and nesting
- Bubble menu: bold, italic, underline, strike, code, link, text/background color
- Markdown shortcuts:
#,-,1.,[],>,```,--- - Blocks: text, headings (H1–H4), lists, to-do, quote, divider, code, image, video/embed, callout, toggle, toggle headings, table, columns — plus opt-in table of contents, math (KaTeX), Mermaid diagrams, emoji picker, file/audio/video uploads and @mentions
- Pick the blocks each app loads; heavy libraries load only when a document needs them
- Vietnamese, English and Simplified Chinese UI, themable with CSS variables, ships its own CSS (no Tailwind needed)
Install
npm i @toantranmei/editor
# or: pnpm add @toantranmei/editor / yarn add @toantranmei/editorRequires Vue ^3.5. Works with any Vite-based app.
Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@toantranmei/editor/nuxt'],
})The module registers <MeiEditor> as a client-only component and adds the CSS.
<script setup lang="ts">
import type { JSONContent } from '@toantranmei/editor'
const doc = ref<JSONContent | null>(null)
</script>
<template>
<MeiEditor v-model="doc" />
</template>Module options (meiEditor key in nuxt.config):
| Option | Default | Description |
| --------------- | ---------------- | --------------------------------------------- |
| componentName | 'MeiEditor' | Name of the auto-registered component |
| css | true | Add @toantranmei/editor/style.css automatically |
| prebundle | true | Pre-bundle Tiptap deps in Vite dev |
| blocks | – | Block names loaded app-wide ('default' = defaultBlocks) |
| tokens | – | Design token overrides for every editor |
Vue 3 + Vite
<script setup lang="ts">
import { ref } from 'vue'
import { MeiEditor, type JSONContent } from '@toantranmei/editor'
import '@toantranmei/editor/style.css'
const doc = ref<JSONContent | null>(null)
</script>
<template>
<MeiEditor v-model="doc" />
</template>Or register it globally:
import { createApp } from 'vue'
import { MeiEditorPlugin } from '@toantranmei/editor'
import '@toantranmei/editor/style.css'
createApp(App).use(MeiEditorPlugin).mount('#app')Choosing blocks
Each block is a self-contained module (extensions + menu entries + UI). The editor loads
defaultBlocks unless you pass blocks:
import { defaultBlocks, emoji, file, html, math, mention, mermaid, toc } from '@toantranmei/editor'
const blocks = [
...defaultBlocks,
toc,
emoji,
math, // needs `npm i katex` + import 'katex/dist/katex.min.css'
mermaid, // needs `npm i mermaid`
html, // sandboxed HTML/CSS/JS; html.configure({ network: ['https://cdn.jsdelivr.net'] })
file.configure({ upload: async (f, kind) => uploadToS3(f), maxSize: 20 }),
mention.configure({
search: q => api.searchPeopleAndPages(q), // → [{ id, label, kind: 'user' | 'page', avatar?, href? }]
onClick: item => router.push(`/u/${item.id}`),
}),
]<MeiEditor v-model="doc" :blocks="blocks" />App-wide: app.use(MeiEditorPlugin, { blocks }), or in Nuxt meiEditor: { blocks: ['default', 'toc', 'math'] }
(only the listed blocks are imported; the module also adds KaTeX's CSS for math).
| Block | In defaultBlocks | Notes |
| --- | --- | --- |
| heading (H1–H4), bulletList, orderedList, taskList, quote, divider | ✓ | heading.configure({ levels: [1, 2, 3] }) |
| codeBlock | ✓ | Grammars load per language on first use |
| image, embed | ✓ | Upload via the uploadImage prop. Images have a Notion-style hover toolbar: align, caption, non-destructive crop, full-screen zoom (wheel/+/−/drag), download, replace / copy link / view original / delete |
| callout, toggle, toggleHeading, table, columns | ✓ | |
| toc | – | Table of contents from headings |
| math | – | Optional peer katex |
| mermaid | – | Optional peer mermaid; code blocks in mermaid show a live diagram |
| html | – | Notion-style HTML block: HTML/CSS/JS in a sandboxed iframe (no access to the page, cookies or storage). Edit in place (Code / Preview) or upload an .html file; fixed height (drag to resize), "Interact" to use it, full-screen view. network: false (default, blocks all requests like Notion), true, or a list of allowed origins; maxSize (KB), defaultHeight (px) |
| emoji | – | Type : + 2 letters; data loads on first use (vi/en search) |
| file | – | File, audio, video; upload, maxSize (MB, base64 when no upload) |
| mention | – | @ people/pages via search; dates (today, 25/12…) built in. Click a date for a Notion-style picker (typed date/time, end date, date & time formats, reminders, clear); click a person/page to search and switch (read-only: onClick / page link). Reminders: onRemind, getMentionReminders(doc), reminderHelpUrl |
Separate packages add more blocks: @toantranmei/page-block
(sub-pages), @toantranmei/database-block (Notion-style
databases: table, board, calendar, timeline, gallery, list, chart, pivot) and
@toantranmei/chart-block (charts, ECharts loaded lazily).
Custom blocks
import { defineBlock } from '@toantranmei/editor'
import { Receipt } from '@lucide/vue'
export const invoice = defineBlock({
name: 'invoice',
nodes: ['invoice'],
extensions: () => [InvoiceNode], // your Tiptap node
items: () => [{ id: 'invoice', icon: Receipt, group: 'advanced', keywords: ['bill'], insert: c => c.insertInvoice() }],
messages: { vi: { blocks: { invoice: { title: 'Hoá đơn', description: 'Chèn hoá đơn' } } } },
// Optional: static HTML for `renderHTML` (see "Server-side rendering"); must not touch the DOM.
render: () => ({ nodes: { invoice: (node, ctx) => `<div class="invoice"${ctx.blockAttrs(node)}>${ctx.escape(node.attrs?.total)}</div>` } }),
// Optional: buttons in the selection bubble menu; `readOnly: true` also shows them in read-only editors.
formatActions: () => [{ id: 'cite', icon: Quote, title: 'Cite', run: editor => cite(editor) }],
})Documents with blocks that aren't loaded
Content using a block type this editor doesn't load (e.g. a doc written where math was enabled)
is kept intact: it shows as an "unsupported block" placeholder that can be moved or deleted, and
the original JSON comes back unchanged in v-model. (Unknown marks are dropped.)
Props
| Prop | Type | Default | Description |
| -------------- | --------------------------------- | ------- | -------------------------------------------------------- |
| modelValue | JSONContent \| null | null | Document as Tiptap JSON (v-model) |
| editable | boolean | true | Read-only when false |
| autofocus | boolean \| 'start' \| 'end' | false | Focus on mount |
| locale | 'vi' \| 'en' \| 'zh' | 'vi' | UI language (zh: Simplified Chinese) |
| messages | PartialMessages | – | Override any UI string |
| blocks | BlockDefinition[] | defaultBlocks | Blocks to load (read at mount), see "Choosing blocks" |
| uploadImage | (file: File) => Promise<string> | – | Upload an image and return its URL (default: base64) |
| maxImageSize | number | 5 | Max image size in MB |
| extensions | AnyExtension[] | – | Extra Tiptap extensions |
| theme | 'auto' \| 'light' \| 'dark' | 'auto'| Color mode; auto follows <html class="dark"> |
| tokens | ThemeTokens | – | Design token overrides for this editor (see Theming) |
| blockLink | (id: string) => string | – | URL for "Copy link to block" (default: page URL + #id) |
| outline | boolean \| { minHeadings } | true | Floating outline on the right (shown from 2 headings) |
| collaboration | { document: Y.Doc, field?: string } | – | Edit a shared Yjs document (read at mount). field defaults to 'default'. See "Collaboration (Yjs)" |
Events: update:modelValue (not in collaboration mode), change({ editor }) (every local or remote change, nothing serialized), ready(editor), content-error({ fatal, issues, error }). The Tiptap editor is also exposed via a template ref (ref.editor).
Collaboration (Yjs)
Pass a Y.Doc and the editor reads and writes it directly (Tiptap's Collaboration extension):
- The
Y.Docis the source of truth:modelValueis never loaded (it would overwrite the shared document) andupdate:modelValueis not emitted. Listen tochangeinstead. It fires on every change and doesn't serialize anything, so debounce your own work and calleditor.getJSON()there. - Undo / redo is Yjs undo (only your own changes), with the same keys:
Mod-z,Mod-Shift-z,Mod-y. - Block ids and every built-in block work as usual in a shared document.
- The prop is read once at mount: to switch documents, re-mount the editor (
:key="pageId"). - Yjs loads only for collaborative editors (
@toantranmei/editor/collaborationis a separate chunk). Installyjs(^13.6, an optional peer) in your app, and keep a single copy of it: two copies of Yjs break the sync.
npm i yjs y-indexeddb<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import * as Y from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
import { MeiEditor, type Editor } from '@toantranmei/editor'
const props = defineProps<{ pageId: string }>()
const doc = new Y.Doc()
const local = new IndexeddbPersistence(`page:${props.pageId}`, doc) // offline copy in this browser
// const remote = new WebsocketProvider(url, props.pageId, doc) // + any provider for live sync
const saveTitle = (title: string) => { /* your API */ }
let timer: ReturnType<typeof setTimeout> | undefined
function onChange({ editor }: { editor: Editor }) {
clearTimeout(timer)
timer = setTimeout(() => saveTitle(editor.state.doc.firstChild?.textContent ?? ''), 1000)
}
onBeforeUnmount(() => { local.destroy(); doc.destroy() })
</script>
<template>
<MeiEditor :collaboration="{ document: doc, field: 'default' }" @change="onChange" />
</template>Presence (carets)
Pass a y-protocols awareness and a lookup of who each Yjs client is:
import { presenceColor, scrollToPeer } from '@toantranmei/editor/collaboration'
const collaboration = {
document: doc,
presence: {
awareness: provider.awareness,
// Server-trusted identity; null hides that client. Never read names / colours from awareness states.
resolveUser: (clientId: number) => {
const peer = peers.get(clientId)
return peer ? { name: peer.name, color: presenceColor(peer.colorIndex), avatarUrl: peer.avatarUrl } : null
},
},
}
// avatar click: scrollToPeer(editor, clientId); after `resolveUser` changes its answers: refreshPresence(editor)Remote carets show the name for 3 s after they move (and on hover), selections get a 25 % tint and the block
holding the caret me-peer-block (2px bar + 6 % tint, --me-peer-color). The local caret is published in the
awareness field cursor, at most every 50 ms, and never while the editor is read-only. PRESENCE_COLORS has 12
colours with ≥ 3:1 contrast on light and dark backgrounds; name labels darken the colour (labelBackground) so the
white text reaches 4.5:1. The caret stays visible while focus is elsewhere on the page (a comment box, a popover);
it is cleared when the editor becomes read-only, unmounts, or the tab stays hidden for over a minute (restored when
it is visible again). Typing re-shows your label to others even when the caret's position doesn't change.
For a headless setup use createExtensions({ blocks, undoRedo: false }) together with
collaborationExtensions({ document, field }) from @toantranmei/editor/collaboration.
The same entry has fillDocument(doc, json, editor.schema, field?) (seed / replace a shared document's
content in one Yjs transaction) and documentToJSON(doc, field?) (its content as editor JSON).
Server-side rendering
@toantranmei/editor/render turns editor JSON into static HTML, in Node (no DOM, no Vue, no
ProseMirror) or the browser. The markup and me-* classes are the editor's, so
@toantranmei/editor/style.css styles it like a read-only editor: heading anchors (block id, or a slug),
the table of contents as links, code highlighted with lowlight, equations with KaTeX, toggles as
<details>, images with width / alignment / crop / caption, tables, columns, callouts, files, audio,
video, mentions as links, embeds and HTML blocks as sandboxed iframes.
// server/routes/p/[slug].get.ts (Nuxt / Nitro)
import { defaultBlocks, math, mention, toc } from '@toantranmei/editor' // or only render hooks, see below
import { renderHTMLAsync, renderTokensStyle } from '@toantranmei/editor/render'
import { pageBlockRender } from '@toantranmei/page-block/render'
const html = await renderHTMLAsync(page.doc, {
locale: page.lang, // 'vi' | 'en' | 'zh'
blocks: [pageBlockRender], // render hooks of non-built-in blocks
resolveLink: id => ({ href: `/notes/p/${slug}/${id}`, title: titles[id], icon: icons[id] }), // null = missing
})
// <head>: '@toantranmei/editor/style.css' (+ 'katex/dist/katex.min.css' with math) and renderTokensStyle()| Option | Description |
| --- | --- |
| blocks | Blocks whose render hooks are used (BlockDefinitions or { name, render }). Built-in blocks always render |
| locale, messages | UI strings (TOC label, "Untitled"…) and date formats |
| resolveLink(pageId) | { href, title?, icon? } or null for page links (@toantranmei/page-block) |
| mentionHref(mention) | Link for person / page mentions (default: the stored href) |
| katex | import katex from 'katex' to render equations synchronously; renderHTMLAsync loads it when a document has math. Without it: the TeX source |
| wrap | Default true: <div class="me-scope me-root me-render"><div class="me-content">…</div></div> |
| theme, className | 'light' / 'dark' force a mode (default follows <html class="dark">), extra wrapper classes |
| externalLinksInNewTab | Default true (target="_blank" rel="noopener noreferrer nofollow") |
| data | Anything custom renderers need (e.g. syncedBlocks, database rows) |
Safety: every text and attribute is escaped; link URLs are limited to http:, https:, mailto: and
relative URLs (safeUrl), media also to data:image|audio|video; embeds need an absolute http(s) URL and
keep the editor's sandbox; HTML blocks run in sandbox without allow-same-origin (code kept in srcdoc).
Also exported: renderHTML (sync), hasMath, escapeHtml, attr, safeUrl, highlightCode,
renderTokensStyle(options?) (<style id="mei-editor-tokens"> with the default tokens, which the editor
otherwise injects at runtime), and hydrateMermaid(root?): a browser helper that turns
<pre class="mermaid"> into diagrams (loads the optional mermaid peer). A 1,000-block document renders in
~5 ms.
renderTokensStyle takes either token overrides (ThemeTokens, like <MeiEditor :tokens>; dark follows
<html class="dark"> / .me-dark) or { light, dark } (with a light key, {} allowed; { dark } alone is the
overrides form): then the page also follows the OS, with the default dark
tokens and dark under @media (prefers-color-scheme: dark) (.me-light opts out). Both parts accept a token tree
({ color: { text: '#222' } }) or a flat map ({ 'color.text': '#222' } / { '--me-color-text': '#222' }).
const style = renderTokensStyle({ light: appTokens, dark: { color: { text: '#eee', bg: '#191919' } } }) // into <head>Heavy blocks load on first use
Blocks keep their schema in the main bundle (a document must parse before anything renders) and load their heavy code the first time a page shows one, so apps don't need a wrapper:
| Block | Loaded lazily | Shown meanwhile |
| --- | --- | --- |
| math | KaTeX (loadKatex(), optional peer) | TeX source |
| mermaid | Mermaid (loadMermaid(), optional peer) | code |
| codeBlock | highlight.js grammar per language | plain code |
| emoji | emoji data per locale | – |
| @toantranmei/database-block | the database UI (DatabaseView, TanStack) | loading box at the view's last height (this session), else header + 3 rows (database.loadingHeight) |
| @toantranmei/chart-block | ECharts | chart frame at its height |
| @toantranmei/sketch-block | the board engine (on first interaction / preview) | preview at the block's height |
| collaboration | Yjs, y-tiptap, presence (@toantranmei/editor/collaboration) | – |
Bundlers put each in its own chunk; keep these packages external to your own vendor chunk so the split survives.
Static rendering (/render entries) never loads them.
Keyboard & mouse
| Action | Shortcut |
| --- | --- |
| Select the current block / clear the selection | Esc |
| Select several blocks | Shift+click, or drag a text selection across blocks |
| Move / extend the block selection | ↑ ↓ / Shift+↑ ↓ |
| Delete selected blocks | Backspace / Delete |
| Edit the selected block | Enter |
| Move block(s) up / down | ⌘⇧↑ / ⌘⇧↓ |
| Duplicate | ⌘D |
| Turn into: text, H1–H3, to-do, bullet, numbered, toggle, code | ⌘⌥0 … ⌘⌥8 |
| Open the block menu | ⌘/ or click ⋮⋮ |
Ctrl replaces ⌘ on Windows/Linux. The block menu has a search box (actions, block types and
colors) and full keyboard navigation (↑ ↓ Enter, → opens a submenu, ←/Esc goes back).
Drag ⋮⋮ to move blocks. Drop on the right edge of a block to place them side by side in
columns (or on the left edge after a clear sideways move); dropping the last block out of a column
removes it. Drop with an indent below a list, toggle or callout to nest inside it. The page
auto-scrolls near the edges.
Floating outline: short bars fixed to the viewport's right edge mirror the page's headings (the
one in view is darker). It stays above full-width content and hides while the editor is off
screen. Hover for the full outline, click to jump. Tune with the outline.* tokens (right,
top…) and heading.scrollMargin (room for a sticky app header); hidden below 768px wide.
Tables: cells hold text only (like Notion's simple tables; no / menu inside cells). Hover a
table for row/column grips (insert, duplicate, move, clear, background color,
delete; header row/column on the first row/column) and "+" bars to add rows and columns.
Select several cells to merge, split, color or clear them. Columns resize by dragging borders.
Blocks get a stable id attribute, used by "Copy link to block"; opening a link with #<id>
scrolls to and selects that block.
Image upload
<MeiEditor v-model="doc" :upload-image="upload" />
<script setup lang="ts">
async function upload(file: File) {
const body = new FormData()
body.append('file', file)
const res = await fetch('/api/upload', { method: 'POST', body })
return (await res.json()).url
}
</script>Paste and drop files
Pasting or dropping images and files anywhere in the editor inserts them at the caret / drop point (several in
order; a text selection is replaced): images become image blocks with an instant preview, other files file blocks
(audio / video / file from the mime type; needs the file block). Uploads go through uploadImage and
file.configure({ upload }), which may report progress with their optional second / third argument
onProgress(fraction). Too large (maxImageSize, file maxSize), unsupported image formats and failed uploads
stay on the block with Retry / Delete (only in the tab that has the file; other editors see "Uploading…"). An upload
still running after 2 minutes with nobody holding its file (the tab closed) shows "Upload interrupted" with Delete.
Pastes that carry text or web images (<img src="https://…">) keep the regular paste; Word's local images
(<img src="file:///…">) are uploaded from the clipboard's files, in place.
Leaving the page while a file uploads: a collaborative editor that is destroyed meanwhile writes the result into its Yjs document once the upload ends (an editor opened again on that document shows the upload's progress). Keep the document open until then:
import { uploadsSettled } from '@toantranmei/editor'
// before the editor unmounts
const settled = uploadsSettled(editor)
settled ? settled.finally(() => provider.destroy()) : provider.destroy()Safe loading
Invalid content never blanks the editor (Tiptap's default, which the next save would persist):
- Blocks with fixable problems (e.g. empty text nodes) are repaired.
- Broken blocks are kept verbatim as an "invalid block" placeholder; their JSON comes back
unchanged in
v-model. Typing while one is selected adds text below instead of replacing it. - Input that isn't a document at all makes the editor read-only, shows a notice and stops
emitting
update:modelValue, so saved data isn't overwritten.
Every case emits content-error so the app can log or report it.
Performance
Measured on a production build (Chrome, Apple Silicon), mixed content with lists, code, callouts and tables. Key latency includes two animation frames (~33ms floor).
| Blocks | Open | Typing (p50) | | --- | --- | --- | | 1,000 | ~0.1 s | 33 ms | | 5,000 | ~0.3 s | 33 ms | | 10,000 | ~0.5 s | ~44 ms | | 20,000 | ~1 s | ~98 ms |
Up to ~10k blocks per page the editor stays smooth. For larger content, split it into
sub-pages (Notion's approach): keep each document small and link pages together, e.g. with
mention page links or @toantranmei/page-block (Notion-style sub-pages). Run pnpm bench in the repo to measure.
Theming
All visual values live in one design-token file (src/theme/tokens.ts) and are exposed as
CSS variables (--me-*). Stylesheets only reference tokens, so changing a token restyles
every place that uses it.
primitive → raw values --me-gray-800, --me-azure-500, --me-space-4, --me-rounded-md
semantic → meaning (light/dark) --me-color-text, --me-color-accent, --me-radius-md, --me-palette-red-text
component → per-part knobs --me-menu-item-radius, --me-box-padding, --me-block-box-spacingUsually you only override semantic tokens; reach for component tokens to fine-tune one part.
Light / dark mode
theme="auto" (default) follows <html class="dark">, the convention used by Tailwind and
@nuxtjs/color-mode. Force a mode per editor with theme="light" or theme="dark".
Override tokens (typed, with autocomplete)
import { defineTokens } from '@toantranmei/editor'
const tokens = defineTokens({
color: { accent: '#7c3aed' }, // both modes
radius: { md: '10px' },
font: { sans: 'Inter, sans-serif' },
components: { menu: { itemRadius: '8px' }, box: { padding: '16px 20px' } },
dark: { color: { accent: '#a78bfa' } }, // dark mode only
})Apply them at the level you need:
<!-- one editor -->
<MeiEditor v-model="doc" :tokens="tokens" />// every editor in a Nuxt app (compiled to a static stylesheet)
export default defineNuxtConfig({
modules: ['@toantranmei/editor/nuxt'],
meiEditor: { tokens: { color: { accent: '#7c3aed' } } },
})
// every editor in a Vue app
app.use(MeiEditorPlugin, { tokens: { color: { accent: '#7c3aed' } } })Or with plain CSS, e.g. to map the editor onto your design system's variables:
.me-scope {
--me-color-accent: var(--ui-primary);
--me-font-sans: var(--font-sans);
--me-radius-md: var(--radius);
}Overriding styles directly
Library CSS sits in the mei-editor cascade layer, declared as
@layer theme, base, mei-editor, components, utilities;. Resets in base (such as Tailwind's
preflight) don't affect the editor, while your own CSS — unlayered, or in components /
utilities — overrides it without !important. Every part has a stable me-* class
(.me-menu-item, .me-callout, .me-code-block, …).
Match a shadcn/ui + Tailwind app
Point the editor's semantic tokens at shadcn's CSS variables, and it follows the app's theme,
including dark mode (both use .dark on <html>). In shadcn, accent is the subtle hover
background and primary is the brand color, so map them that way round:
import { defineTokens } from '@toantranmei/editor'
// Tailwind v4 / shadcn v4 variables are full colors. On Tailwind v3 (HSL triplets) use 'hsl(var(--primary))'.
export const tokens = defineTokens({
color: {
text: 'var(--foreground)',
textMuted: 'var(--muted-foreground)',
bg: 'var(--background)',
elevated: 'var(--popover)',
surface: 'var(--muted)',
hover: 'var(--accent)',
border: 'var(--border)',
accent: 'var(--primary)',
onAccent: 'var(--primary-foreground)',
danger: 'var(--destructive)',
},
radius: { sm: 'calc(var(--radius) - 4px)', md: 'calc(var(--radius) - 2px)', lg: 'var(--radius)' },
font: { sans: 'var(--font-sans)', mono: 'var(--font-mono)' },
components: { input: { focusRing: 'var(--ring)' } },
})Tailwind v4 works out of the box (its preflight lives in the base layer). Tailwind v3 emits its
preflight outside any cascade layer, which beats layered CSS, so it can reset editor buttons,
lists and headings.
Colors in documents
Text, highlight and callout colors are stored by name (data-text-color="red"), not as hex,
so documents follow your theme and dark mode. Change the palette with
palette: { red: { text: '…', bg: '…' } }.
Advanced
Everything is exported for building your own UI on the same blocks:
createExtensions, BLOCKS, filterBlocks, turnBlockInto, the custom extensions
(Callout, Columns, ImageBlock, Embed, CodeBlock, SlashCommand, BlockDrop, TextColor, BgColor),
the token objects (primitiveTokens, semanticLightTokens, semanticDarkTokens, componentTokens) and locales (vi, en, zh).
License
MIT
