Frameworks

Vue

npm i @matrajs/vue

Vue is first-class here rather than a community package that lags the React one. Both bindings are written together, tested together and released together, against the same version number. The engine comes with it · there is no second package.

An editor

<script setup lang="ts">
import { starterKit } from '@matrajs/core'
import { EditorContent, useEditor } from '@matrajs/vue'

const editor = useEditor({
  extensions: starterKit,
  content: '<p>Hello.</p>',
})
</script>

<template>
  <EditorContent :editor="editor" />
</template>

The editor is destroyed when the component unmounts, or when the surrounding effect scope is stopped — so it also works outside a component, in a composable or a store. It is markRawped, so Vue never walks the document making it reactive: the editor publishes its own changes and a proxy over a live document would cost a great deal to achieve nothing.

A toolbar that stays honest

Commands mutate the document; Vue does not hear about that on its own, so a naive toolbar goes stale the moment the caret moves. useEditorState returns a readonly ref that follows both changes and selection changes.

<script setup lang="ts">
import type { Editor } from '@matrajs/core'
import { useEditorState } from '@matrajs/vue'

const props = defineProps<{ editor: Editor }>()

const bold = useEditorState(props.editor, (e) => e.isActive('bold'))
const canBold = useEditorState(props.editor, (e) => e.can.toggleBold())
const characters = useEditorState(props.editor, (e) => e.getText().length)
</script>

<template>
  <button
    :aria-pressed="bold"
    :disabled="!canBold"
    @mousedown.prevent="editor.commands.toggleBold()"
  >
    Bold
  </button>
  <span>{{ characters }}</span>
</template>
@mousedown.prevent rather than @click. A click moves focus to the button first and collapses the selection, so the command runs against a caret instead of the words that were highlighted.

can is the same command asking rather than doing, which is how the button knows to be disabled instead of looking enabled and doing nothing — the caret is in a code block, or the selection cannot hold that mark.

Focus

const focused = useEditorFocus(editor)

A readonly ref · useful for showing a toolbar only while the editor has the caret.

Saving

<script setup lang="ts">
import { onScopeDispose } from 'vue'

const editor = useEditor({ extensions: starterKit })

onScopeDispose(editor.on('change', () => save(editor.getJSON())))
</script>

on returns its own unsubscribe function, so it hands straight to onScopeDispose with nothing in between.

v-model, if you want it

There is no v-model on EditorContent on purpose: a two-way binding over a live document means re-parsing it on every keystroke, and the parse is what loses a selection. Write it yourself where you actually need it, and only in one direction.

const model = defineModel<string>()

const editor = useEditor({
  extensions: starterKit,
  content: model.value,
})

onScopeDispose(editor.on('change', () => {
  model.value = editor.getHTML()
}))
Setting model.value from outside will not push back into the editor, which is the behaviour you want · call editor.commands or editor.setContent() for a deliberate change instead.

Nuxt

The editor touches the DOM only when it mounts, so it renders on the server without a shim. Keep EditorContent out of the server pass and the rest works untouched:

<template>
  <ClientOnly>
    <EditorContent :editor="editor" />
  </ClientOnly>
</template>

useEditor itself runs on the server — the editor exists before anything is on screen, so getJSON(), getHTML() and getText() all answer during a server render, with no DOM polyfill.

With one condition: server-side content must be JSON or Markdown, not an HTML string. Reading HTML is a DOM job, so content: '<p>…</p>' needs a browser · content: fromMarkdown(text) and content: storedJson do not. Writing HTML out with getHTML() works either way.

KeepAlive

The mount is guarded, so a component brought back by <KeepAlive> or a hot reload does not attach a second view to one element and leave two carets fighting over it.

Edit this page on GitHub