CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Component Helpers

Rich-Text Editor

The template's TipTap editor for HTML fields — load it only when an admin edits, keep it out of visitors' prefetch hints, choose its buttons, and edit together with other admins.

The module doesn't ship a rich-text editor. The template has one, app/app/components/TipTapHtmlEditor.vue, built on TipTap. Its HtmlContent component shows the editor to admins in edit mode, and the HTML through v-cwa-html to everyone else. This page covers the editor; HTML Content covers rendering the HTML.

Loading an Editor Lazily

A rich-text component often shows an editor to admins, such as TipTap. Import the editor with defineAsyncComponent, not statically. A static import puts the editor into the preloads of every page with body text, for anonymous visitors too. In the template, that was about 424 KB of TipTap and ProseMirror (135 KB gzipped).

<template>
  <article>
    <TipTapHtmlEditor v-if="$cwa.admin.isEditing" :model="resourceModel" :iri="iri" />
    <div v-else ref="container" v-cwa-html="content" class="prose" />
  </article>
</template>

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

// Fetched only when an admin starts editing.
const TipTapHtmlEditor = defineAsyncComponent(() => import('~/components/TipTapHtmlEditor.vue')) // ...
</script>

Anywhere else that only needs the editor's type, use import type. A value import keeps the editor in the dev bundle.

import type TipTapHtmlEditor from '~/components/TipTapHtmlEditor.vue'

const editorComponent = ref<typeof TipTapHtmlEditor | undefined>()

Nuxt still adds a prefetch hint for the async chunk, so browsers may download it in the background. The template removes that hint with a build:manifest hook in nuxt.config.ts. It filters the editor out of each chunk's dynamicImports. The editor still loads on demand when it is first shown.

// nuxt.config.ts
const isEditorSource = (id: string) => id.endsWith('components/TipTapHtmlEditor.vue')

export default defineNuxtConfig({
  modules: [
    (_options, nuxt) => {
      nuxt.hook('build:manifest', (manifest) => {
        for (const chunk of Object.values(manifest)) {
          chunk.dynamicImports = chunk.dynamicImports?.filter(id => !isEditorSource(id))
        }
      })
    },
  ],
})

The module already keeps its own admin UI out of visitors' prefetch hints. It can't see your editor, because the editor is app code, so keep this hook.

Choosing the Editor's Buttons

The template's app/app/components/TipTapHtmlEditor.vue shows a menu of formatting buttons: H1, H2, Bold, Italic, Underline, Link and Bullet List. Every button shows by default. Pass the config prop to hide some of them for one field.

For example, a short description that allows only bold, italic and links:

<TipTapHtmlEditor
  v-if="$cwa.admin.isEditing"
  :model="resourceModel"
  :iri="iri"
  :config="{ h1: false, h2: false, underline: false, bulletList: false }"
/>

The keys are h1, h2, bold, italic, underline, link and bulletList. Set a key to false to hide that button. The prop is typed, so a misspelt key fails the type check. A project whose copy of the file has no config prop can copy the current file from the template.

H1, H2 and Bullet List also appear in the menu shown on an empty line. That menu is hidden when all three are off.

Hiding a button doesn't stop the style. The editor still has every extension, so pasted content and keyboard shortcuts (such as Ctrl+U, or Cmd+U on a Mac, for underline) still apply it. config only changes the menu. To forbid a style, remove its extension from the editor, or clean the HTML on the API.
Image placeholder: the editor's selection menu for a field with config set to bold, italic and link only, next to the full menu with all seven buttons.

Editing Together

The editor takes the HTML field's useCwaResourceModel as :model, and the component's IRI as :iri. With them, it works with other admins like the kit fields do:

  • Who's in the field. Another admin with the editor in focus outlines it in their colour, with their name above it (<CwaUiFieldPresence>).
  • Their carets. Each admin's caret and selection show in their colour while they're in the field. The editor sends them with useCwaRealtime.
  • Their saves. Another admin's save is applied in place: only the changed part is replaced, so your caret stays where it was, and Undo doesn't undo their change.
  • Typing at once. Your typing and theirs merge as you both type (see Typing at the Same Time). When a save can't be merged, Use theirs / Keep mine shows below the editor (<CwaUiRemoteChangeConflict>).

How an editor of your own does the same is in useCwaResourceModel › Your Own Field Editor.

IMAGE: The rich-text editor in edit mode, outlined in purple with the flag "Ben Kay" above it, Ben's purple caret in the second paragraph, and "Ben Kay changed this · Use theirs / Keep mine" below the text

Typing at the Same Time

When two admins type in the same field at once, the template's editor merges their typing with Yjs:

  • Each admin sees the other's keystrokes as they type, with live carets.
  • Undo only undoes your own typing.
  • Each window saves its own typing, so Publish includes it. One window also saves the merged text.
  • A change saved from outside the editor, such as from another tab of the manager panel, is applied into the shared text for everyone.
  • The shared Yjs state for each field is kept in the API, so an admin who opens the field later starts from it (see API › Versions & Conflicts › Collaboration States).

It needs api-components-bundle 2.0.0-alpha.37 or later, which stores the Yjs state. With an older bundle, the editor logs a warning and starts from the saved HTML.

The editor is app/app/components/TipTapHtmlEditor.vue with the Yjs files in app/app/tiptap/ (co-editing.ts, y-sync.ts, y-seed.ts, y-session.ts, y-state.ts and y-cursors.ts), and it uses the yjs, @tiptap/extension-collaboration and @tiptap/y-tiptap packages. Pass it the name of the field it edits, as field:

<TipTapHtmlEditor
  v-if="$cwa.admin.isEditing"
  :model="resourceModel"
  :iri="iri"
  field="html"
/>

Load the editor lazily, as in Loading an Editor Lazily, so Yjs never reaches visitors.

Keep one copy of yjs. Two copies in the build break the editor's sync. Keep @tiptap/extension-collaboration on the same version as your other TipTap packages, and check that pnpm why yjs lists one version.
Known limits:
  • An editor that isn't selected shows no one's caret.
  • Nothing stops the stored Yjs state growing past the API's limit for it: 8,388,608 base64 characters, about 6 MiB of Yjs data.
  • If the window that saves the merged text has typing it hasn't sent when an outside save arrives, that admin gets Use theirs / Keep mine instead of a merge.
VIDEO: Two windows on the same HTML component, both with the editor selected. Typing in one window appears letter by letter in the other, each caret in its admin's colour, and pressing Undo in one window removes only that admin's words