Tutorial

Chapter 2: A simple editor

Mount SimpleEditor with a JSON v-model, add the toolbar and the outline beside it, switch it read-only and handle rejected content.

SimpleEditor is the two core features alone (core-content and core-ui): bold, italic, underline, strike, code and link, headings, lists, quotes, code blocks, a horizontal rule, undo and redo. It makes no backend request, has no Yjs and no feature code. This is the right start for meeting notes.

Try it first. The toolbar and the outline sit beside the editor, the JSON pane shows the document the editor holds, and the switch makes it read-only:

The editor context

Every editor component reads shared state (settings, language, backend) from an editor context that the app installs once. createEditor() from @skaile/forge-editor/vue is that context as a Vue plugin; Nuxt UI's ui plugin provides the components. The example's main.ts in full:

examples/vue-simple/src/main.ts
import ui from '@nuxt/ui/vue-plugin'
import { createMemoryBackend } from '@skaile/forge-editor/adapters/memory'
import { createEditor } from '@skaile/forge-editor/vue'
import { nuxtUiKit } from '@skaile/forge-editor/vue/kit/nuxt-ui'
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

// No data layer: documents, dropped files (as blob: URLs), mention users and tags, and settings live in the page.
const backend = createMemoryBackend({
  users: [
    { id: 'u1', username: 'Ada Lovelace', color: '#7c3aed' },
    { id: 'u2', username: 'Alan Turing', color: '#0ea5e9' },
    { id: 'u3', username: 'Grace Hopper', color: '#16a34a' },
  ],
  tags: ['idee', 'entwurf', 'wichtig'],
})

createApp(App)
  .use(ui)
  .use(createEditor({ kit: nuxtUiKit(), backend }))
  .mount('#app')
  • nuxtUiKit() tells the library's Vue layer to render its toolbar, menus and dialogs with Nuxt UI components.
  • createMemoryBackend() (@skaile/forge-editor/adapters/memory) keeps everything in the page: settings in localStorage, dropped files as blob: URLs, and the lists behind @ and #. The simple editor does not use the lists; chapter 3 does. Without a backend option the context talks to the HTTP routes of the Nuxt module instead (Nuxt module).
  • createEditor({ locale: 'en' }) switches the library's own strings to English.

Then the stylesheet. Tailwind, Nuxt UI, then the core CSS, and a @source line so Tailwind generates the utility classes the library's Vue files use:

@import "tailwindcss";
@import "@nuxt/ui";
@import "@skaile/forge-editor/vue/kit/nuxt-ui.css";
@import "@skaile/forge-editor/styles/core.css";
@source "../node_modules/@skaile/forge-editor/dist";

The @source path is relative to the CSS file and points to the installed library's dist directory: the whole of it, because the Features' components (features/*/components) sit beside vue. In this repository the examples point at the sibling's src (../../../packages/editor/src) instead. The order matters: CSS and UI-provider requirements lists every sheet and why it comes where it does.

Mount the editor

v-model carries the document as TipTap JSON. EditorToolbar and EditorOutline are siblings of the editor, not children: they work on exactly the editor you hand them, taken from the ready event. Keep that instance in a shallowRef; a deep ref would wrap the TipTap instance in a Vue proxy.

<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import type { Editor, JSONContent } from '@tiptap/core'
import { SimpleEditor } from '@skaile/forge-editor/vue/simple'
import { EditorToolbar } from '@skaile/forge-editor/vue'
import { EditorOutline } from '@skaile/forge-editor/outline/vue'

const editor = shallowRef<Editor>()
const content = ref<JSONContent>({ type: 'doc', content: [{ type: 'paragraph' }] })
</script>

<template>
  <UApp>
    <EditorToolbar :editor="editor" />
    <SimpleEditor v-model="content" @ready="editor = $event" />
    <EditorOutline :editor="editor" />
  </UApp>
</template>

<UApp> provides the overlays, tooltips and toasts the toolbar uses. The example's own template adds a sample picker, the read-only switch and two output panes. The part that matters here is how the editor, its toolbar and its outline are wired:

examples/vue-simple/src/App.vue
<template>
  <UApp>
    <main class="mx-auto flex max-w-7xl flex-col gap-4 p-6">
      <h1 class="text-xl font-semibold">@skaile/forge-editor – SimpleEditor</h1>

      <div class="flex flex-wrap items-center gap-3">
        <USelect
          v-model="sample"
          :items="SAMPLES"
          value-key="id"
          label-key="label"
          class="w-64"
          aria-label="Beispiel"
          data-testid="sample"
        />
        <USwitch v-model="editable" label="Bearbeitbar" data-testid="editable" />
        <span class="text-sm text-muted" data-testid="edits">Änderungen: {{ edits }}</span>
      </div>

      <UAlert v-if="contentError" color="error" variant="subtle" title="Inhalt abgelehnt" :description="contentError" data-testid="content-error" />

      <div class="grid grid-cols-[14rem_minmax(0,1fr)_20rem] gap-6">
        <aside aria-label="Gliederung">
          <EditorOutline :editor="editor" />
        </aside>
        <div class="flex min-w-0 flex-col gap-3">
          <EditorToolbar :editor="editable ? editor : undefined" />
          <div class="rounded-lg border border-default p-4" data-testid="surface">
            <SimpleEditor
              :model-value="content"
              :editable="editable"
              :extensions="extensions"
              :commands="hostCommands"
              @ready="onReady"
              @update:model-value="onUpdate"
              @content-error="onContentError"
            />
          </div>
        </div>
        <div class="flex min-w-0 flex-col gap-3">
          <section>
            <h3 class="mb-1 text-sm font-semibold">JSON</h3>
            <pre class="max-h-72 overflow-auto rounded-lg bg-elevated p-3 text-xs" data-testid="json">{{ json }}</pre>
          </section>
          <section>
            <h3 class="mb-1 text-sm font-semibold">Markdown</h3>
            <pre class="max-h-72 overflow-auto rounded-lg bg-elevated p-3 text-xs" data-testid="markdown">{{ markdown }}</pre>
          </section>
        </div>
      </div>

Three details:

  • Read-only. editable is reactive. The example hands the toolbar undefined while the editor is read-only; without an editor the toolbar renders no groups.
  • The editor owns the document. v-model mirrors it as JSON. Assigning a new object to the model replaces the document without an update event (the editor calls setContent(json, { emitUpdate: false })), so loading a sample does not count as an edit. JSON the editor emitted itself and you hand back unchanged is recognised and ignored, so the usual v-model round trip keeps the caret and the undo history.
  • Props that are read once. extensions, commands and abilities are read when the component is set up. Pass constants, or remount with a new key.

Rejected content: contentError

The simple schema is not a lossy view of richer documents. Initial and external JSON is checked against the installed schema. A document with unknown node or mark types, for example { type: 'doc', content: [{ type: 'typedBlock' }] } (the node of a Typed block), is rejected: contentError fires with the reason and the last valid document stays in the editor. Nothing is flattened or silently dropped. The v-model value is your own state, so move it back when you want it to follow the editor. This is how the example handles its „Ungültig (Block-JSON)“ sample: the alert shows the reason, and the model and the picker return to the previous sample.

examples/vue-simple/src/App.vue
// `shown` is the sample the editor currently shows. The editor keeps its last valid document when it rejects a
// load; the model and the picker follow it back.
let shown = firstSample.id
let rejectedLoad: { id: string, content: JSONContent } | undefined
watch(sample, (id) => {
  const next = SAMPLES.find(s => s.id === id)
  if (!next || id === shown) return
  rejectedLoad = { id: shown, content: content.value }
  shown = id
  contentError.value = ''
  content.value = next.content
})
function onContentError(error: Error) {
  contentError.value = error.message
  if (!rejectedLoad) return
  content.value = rejectedLoad.content
  shown = rejectedLoad.id
  sample.value = rejectedLoad.id
  rejectedLoad = undefined
}

The full list of props and events of SimpleEditor is in Quick start: plain Vue. The editor is isolated on purpose: bun run check:editor-entries proves that the simple bundle contains no feature, AI SDK, Yjs, Hocuspocus or server module.

What you have now

A Vue app with a working rich-text editor: JSON v-model, a toolbar and an outline that follow the editor you give them, a read-only mode, and a defined behaviour for documents the schema rejects. The editor has no features beyond the core ones yet.

Next

Chapter 3: Compose features replaces SimpleEditor with an editor you assemble from features.

Copyright © 2026