Skip to content

gridigor/vue-lexical

Repository files navigation

@gridigor/vue-lexical

Modern Vue 3 bindings for Lexical, designed around the same small, composable building blocks as @lexical/react.

Version 1.1 provides a Vue equivalent for every public entrypoint and public symbol in @lexical/react@0.48.0, with React rendering contracts adapted to Vue components, composables, slots, emits, and Teleport.

See the API parity roadmap for the current implementation status and the complete API entrypoint matrix for every supported @lexical/react module. The API reference lists every root export and tree-shakeable subpath. Framework-specific contracts are listed in Intentional Vue API differences.

Requirements

  • Vue 3.5 or newer
  • Lexical 0.48.x
  • Node.js 22.12 or newer for development
  • TypeScript 7 for this repository's toolchain

All @lexical/* packages used by an application must resolve to the same version as lexical.

Playground

Try the current components and plugins in the public Vue Lexical demo playground. The playground is a statically generated Nuxt application deployed through GitHub Pages whenever main changes.

Installation

npm install @gridigor/vue-lexical lexical vue

Plain text editor

<script setup lang="ts">
import { $getRoot, type EditorState } from 'lexical'
import {
  AutoFocusPlugin,
  ContentEditable,
  HistoryPlugin,
  LexicalComposer,
  OnChangePlugin,
  PlainTextPlugin,
} from '@gridigor/vue-lexical'

const initialConfig = {
  namespace: 'MyEditor',
  onError(error: Error) {
    throw error
  },
  theme: {},
}

function onChange(editorState: EditorState) {
  editorState.read(() => {
    console.log($getRoot().getTextContent())
  })
}
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <PlainTextPlugin>
      <template #contentEditable>
        <ContentEditable aria-label="Editor">
          <template #placeholder>Start typing…</template>
        </ContentEditable>
      </template>
    </PlainTextPlugin>
    <OnChangePlugin @change="onChange" />
    <HistoryPlugin />
    <AutoFocusPlugin />
  </LexicalComposer>
</template>

initialConfig is read once when the composer creates the editor. Its optional onWarn(error, editor) callback receives recoverable Lexical warnings without treating them as fatal errors. The public InitialConfigType and InitialEditorStateType types match the naming used by @lexical/react; InitialConfig and InitialEditorState remain available as compatibility aliases.

Components can be imported from the package root or through subpaths matching the Lexical naming style:

PlainTextPlugin and RichTextPlugin automatically register Dragon NaturallySpeaking support for the lifetime of the mounted editor, matching @lexical/react.

ContentEditable reads the editor from the nearest composer and supports the placeholder slot. For lower-level integrations, ContentEditableElement accepts an explicit editor prop and binds that editor directly to its rendered element. Both components accept standard Vue HTML/ARIA attributes; the camelCase ARIA compatibility props from @lexical/react are also available.

import { LexicalComposer } from '@gridigor/vue-lexical/LexicalComposer'
import { ContentEditable } from '@gridigor/vue-lexical/LexicalContentEditable'

Inside a plugin, access the current editor with either composable:

import {
  useLexicalComposer,
  useLexicalComposerContext,
} from '@gridigor/vue-lexical/LexicalComposerContext'

const editor = useLexicalComposer()
const [sameEditor, composerContext] = useLexicalComposerContext()
const activeTheme = composerContext.getTheme()

LexicalComposerContext, LexicalComposerContextType, LexicalComposerContextWithEditor, and createLexicalComposerContext() are also public for low-level integrations, matching the upstream context contract.

External editor access and clearing

Use EditorRefPlugin when controls outside the composer need the editor instance. It accepts either a Vue ref or a callback. ClearEditorPlugin registers the standard Lexical CLEAR_EDITOR_COMMAND behavior:

<script setup lang="ts">
import type { LexicalEditor } from 'lexical'
import { CLEAR_EDITOR_COMMAND } from 'lexical'
import { shallowRef } from 'vue'
import { ClearEditorPlugin, EditorRefPlugin } from '@gridigor/vue-lexical'

const editorRef = shallowRef<LexicalEditor | null>(null)

function setEditor(editor: LexicalEditor | null) {
  editorRef.value = editor
}
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <EditorRefPlugin :editor-ref="setEditor" />
    <ClearEditorPlugin />
    <!-- Other editor plugins -->
  </LexicalComposer>

  <button type="button" @click="editorRef?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined)">
    Clear
  </button>
</template>

In a Vue template, refs are automatically unwrapped when passed as props, so the callback form is the most direct option. In render functions, a shallowRef can be passed to editorRef without unwrapping it.

Text limits

CharacterLimitPlugin keeps all entered text but wraps the part beyond the limit in an OverflowNode, allowing it to be highlighted. Register that node in the composer and use the default counter or a scoped slot:

<script setup lang="ts">
import { OverflowNode } from '@lexical/overflow'
import { CharacterLimitPlugin } from '@gridigor/vue-lexical'

const initialConfig = {
  namespace: 'LimitedEditor',
  nodes: [OverflowNode],
  onError(error: Error) {
    throw error
  },
}
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <!-- Editor plugins -->
    <CharacterLimitPlugin :max-length="280">
      <template #default="{ remainingCharacters, exceeded }">
        <span :class="{ exceeded }">{{ remainingCharacters }}</span>
      </template>
    </CharacterLimitPlugin>
  </LexicalComposer>
</template>

Set charset="UTF-8" to count encoded bytes instead of the default UTF-16 code units. For a hard input limit, use <MaxLengthPlugin :max-length="280" />; it trims text inserted beyond the boundary.

useLexicalIsTextContentEmpty(editor, trim) reactively reports whether the editor is empty. The editor argument can be omitted inside a composer, and trim defaults to true in accordance with @lexical/text.

Tab indentation

TabIndentationPlugin turns Tab and Shift+Tab into indent and outdent commands. An optional maxIndent limits nesting, while canIndent restricts which block elements may be indented:

<LexicalComposer :initial-config="initialConfig">
  <!-- Editor plugins -->
  <TabIndentationPlugin :max-indent="4" :can-indent="node => node.canIndent()" />
</LexicalComposer>

Enabling this behavior can trap keyboard focus inside the editor. Only use it when editor indentation is more important than allowing Tab to move focus to the next control.

Links

Register LinkNode in the composer and add LinkPlugin to enable TOGGLE_LINK_COMMAND, link normalization, URL validation, and link creation from pasted URLs:

<script setup lang="ts">
import { AutoLinkNode, LinkNode } from '@lexical/link'
import {
  AutoLinkPlugin,
  ClickableLinkPlugin,
  LinkPlugin,
  createLinkMatcherWithRegExp,
} from '@gridigor/vue-lexical'

const urlMatcher = createLinkMatcherWithRegExp(/https?:\/\/[^\s]+/, (text) => text)

const initialConfig = {
  namespace: 'LinkEditor',
  nodes: [LinkNode, AutoLinkNode],
  onError(error: Error) {
    throw error
  },
}
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <!-- RichTextPlugin and other editor plugins -->
    <LinkPlugin :validate-url="(url) => url.startsWith('https://')" />
    <AutoLinkPlugin :matchers="[urlMatcher]" />
    <ClickableLinkPlugin :new-tab="true" />
  </LexicalComposer>
</template>

AutoLinkPlugin requires AutoLinkNode and also accepts onChange and excludeParents. ClickableLinkPlugin opens links in a new tab by default; set :new-tab="false" to reuse the current tab or disabled to temporarily turn click navigation off.

Lists

Register ListNode and ListItemNode in the composer, then add ListPlugin to enable ordered and unordered lists. Add CheckListPlugin to enable the check-list command and pointer and keyboard interaction for check-list items:

<script setup lang="ts">
import { ListItemNode, ListNode } from '@lexical/list'
import { CheckListPlugin, ListPlugin } from '@gridigor/vue-lexical'

const initialConfig = {
  namespace: 'ListEditor',
  nodes: [ListNode, ListItemNode],
  onError(error: Error) {
    throw error
  },
}
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <!-- RichTextPlugin and other editor plugins -->
    <ListPlugin :has-strict-indent="true" :should-preserve-numbering="true" />
    <CheckListPlugin />
  </LexicalComposer>
</template>

Use INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, INSERT_CHECK_LIST_COMMAND, and REMOVE_LIST_COMMAND from @lexical/list to control list formatting. Set disable-take-focus-on-click on CheckListPlugin when toggling a checkbox must not focus the editor.

Markdown shortcuts

MarkdownShortcutPlugin converts Markdown syntax as it is typed. Its default transformers support headings, quotes, lists, fenced code, text formatting, links, and horizontal rules. Register every node required by the transformers you use:

<script setup lang="ts">
import { HeadingNode } from '@lexical/rich-text'
import { HEADING } from '@lexical/markdown'
import { MarkdownShortcutPlugin } from '@gridigor/vue-lexical'

const initialConfig = {
  namespace: 'MarkdownEditor',
  nodes: [HeadingNode],
  onError(error: Error) {
    throw error
  },
}

const transformers = [HEADING]
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <!-- RichTextPlugin and other editor plugins -->
    <MarkdownShortcutPlugin :transformers="transformers" />
  </LexicalComposer>
</template>

Omit transformers to use DEFAULT_TRANSFORMERS, which also requires HorizontalRuleNode, HeadingNode, QuoteNode, ListNode, ListItemNode, CodeNode, and LinkNode in the composer configuration.

Hashtags

Register HashtagNode in the composer and add HashtagPlugin to transform text such as #vue into a text entity while the user types:

<script setup lang="ts">
import { HashtagNode } from '@lexical/hashtag'
import { HashtagPlugin } from '@gridigor/vue-lexical'

const initialConfig = {
  namespace: 'HashtagEditor',
  nodes: [HashtagNode],
  onError(error: Error) {
    throw error
  },
}
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <!-- RichTextPlugin and other editor plugins -->
    <HashtagPlugin />
  </LexicalComposer>
</template>

Use the hashtag key in the Lexical theme to style generated hashtag nodes.

For custom entities such as mentions, useLexicalTextEntity registers the same pair of forward and reverse transforms used by the built-in entity plugins. Call it during setup inside a composer:

import type { TextNode } from 'lexical'
import { useLexicalTextEntity } from '@gridigor/vue-lexical'
import { $createMentionNode, MentionNode } from './MentionNode'

useLexicalTextEntity(
  (text) => {
    const match = /@[a-z0-9_]+/i.exec(text)
    return match ? { start: match.index, end: match.index + match[0].length } : null
  },
  MentionNode,
  (textNode: TextNode) => $createMentionNode(textNode.getTextContent()),
)

getMatch, targetNode, and createNode may also be Vue refs. Changing one automatically replaces the registered transforms.

Node events

NodeEventPlugin delegates a DOM event from the editor root and reports the key of the matching Lexical node. It can match the event target itself or one of its Lexical parents, so a single listener handles every node of a class:

<script setup lang="ts">
import { LinkNode } from '@lexical/link'
import type { LexicalEditor, NodeKey } from 'lexical'
import { NodeEventPlugin } from '@gridigor/vue-lexical'

function onLinkClick(event: Event, _editor: LexicalEditor, nodeKey: NodeKey) {
  console.log('Clicked link', nodeKey, event)
}
</script>

<template>
  <LexicalComposer :initial-config="initialConfig">
    <!-- RichTextPlugin and other editor plugins -->
    <NodeEventPlugin :node-type="LinkNode" event-type="click" :event-listener="onLinkClick" />
  </LexicalComposer>
</template>

mouseenter and mouseleave use capture semantics and match only the nearest Lexical node, consistent with @lexical/react.

Typeahead menus

TypeaheadMenuPlugin opens a teleported menu when the text before the cursor matches a trigger. The built-in renderer supports mouse selection, arrow keys, Enter, Tab, Escape, active-descendant ARIA attributes, viewport repositioning, and query-node splitting:

<script setup lang="ts">
import { $createTextNode, type TextNode } from 'lexical'
import {
  MenuOption,
  TypeaheadMenuPlugin,
  createBasicTypeaheadTriggerMatch,
} from '@gridigor/vue-lexical'

class MentionOption extends MenuOption {
  constructor(
    key: string,
    public label: string,
  ) {
    super(key)
    this.title = label
  }
}

const options = [new MentionOption('vue', 'Vue'), new MentionOption('lexical', 'Lexical')]
const triggerFn = createBasicTypeaheadTriggerMatch('@', { minLength: 0 })

function onSelectOption(option: MenuOption, queryNode: TextNode | null, close: () => void) {
  queryNode?.replace($createTextNode(`@${String(option.title)}`))
  close()
}
</script>

<template>
  <TypeaheadMenuPlugin
    :options="options"
    :trigger-fn="triggerFn"
    :on-query-change="(query) => console.log(query)"
    :on-select-option="onSelectOption"
  />
</template>

Use the default scoped slot to replace the menu markup while retaining the positioning and keyboard behavior. It receives options, selectedIndex, matchingString, setHighlightedIndex, and selectOptionAndCleanUp. Custom floating-menu components can call useDynamicPositioning() during setup to react to scroll, window resize, target resize, visibility changes, and enclosing shadow-root scrolls. The deprecated getScrollParent compatibility export matches @lexical/react; new code should import it from @lexical/utils.

Node and context menus

NodeMenuPlugin provides the same keyboard-aware floating menu for a known Lexical nodeKey. It opens while the node exists and automatically tracks its DOM position. NodeContextMenuPlugin replaces the browser menu inside the editor and filters actions against the right-clicked Lexical node:

<script setup lang="ts">
import { $getRoot } from 'lexical'
import {
  NodeContextMenuOption,
  NodeContextMenuPlugin,
  NodeContextMenuSeparator,
} from '@gridigor/vue-lexical'

const items = [
  new NodeContextMenuOption('Select all', {
    $onSelect: () => {
      const root = $getRoot()
      root.select(0, root.getChildrenSize())
    },
  }),
  new NodeContextMenuSeparator(),
  new NodeContextMenuOption('Clear', { $onSelect: () => $getRoot().clear() }),
]
</script>

<template>
  <NodeContextMenuPlugin :items="items" />
</template>

Context menus support disabled actions, separators, $showOn node predicates, arrow/Home/End navigation, Enter/Space selection, Escape, and keyboard type-ahead. Both node menu components expose scoped slots for custom markup.

Auto embeds

AutoEmbedPlugin watches newly pasted LinkNode and AutoLinkNode instances, matches their URL against embedConfigs, and opens a node menu. Each config supplies parseUrl and insertNode; getMenuOptions decides whether the user can embed or dismiss the detected URL. The exported INSERT_EMBED_COMMAND, AutoEmbedOption, and URL_MATCHER mirror the corresponding React helpers.

Block elements

Register HorizontalRuleNode in the composer and mount HorizontalRulePlugin to insert selectable horizontal rules with INSERT_HORIZONTAL_RULE_COMMAND:

<script setup lang="ts">
import {
  HorizontalRuleNode,
  HorizontalRulePlugin,
  INSERT_HORIZONTAL_RULE_COMMAND,
  useLexicalComposer,
} from '@gridigor/vue-lexical'

const editor = useLexicalComposer()
// Add HorizontalRuleNode to initialConfig.nodes.
</script>

<template>
  <button @click="editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)">
    Insert rule
  </button>
  <HorizontalRulePlugin />
</template>

DecoratorBlockNode is the Vue base class for aligned block decorators. Render its content through BlockWithAlignableContents to receive node selection, Shift-click multi-selection, and FORMAT_ELEMENT_COMMAND support. The lower-level useLexicalNodeSelection(nodeKey) composable returns a readonly selection ref, a setter, and a clear function.

DraggableBlockPlugin Teleports a drag handle and target line into an anchor element and reorders top-level nodes. Supply the visuals through the menu and targetLine slots:

<DraggableBlockPlugin :anchor-element="editorContainer">
  <template #menu>⋮⋮</template>
  <template #targetLine><div class="drop-line" /></template>
</DraggableBlockPlugin>

Use SelectionAlwaysOnDisplay when toolbar interaction should not hide the editor's visual range highlight. Its optional onReposition callback receives the current highlight elements.

Tables

Register TableNode, TableRowNode, and TableCellNode, then mount TablePlugin. The Vue API mirrors @lexical/react/LexicalTablePlugin: cell merge, cell background, Tab navigation, horizontal scrolling, and the experimental nested-table policy are controlled by component props.

<script setup lang="ts">
import {
  INSERT_TABLE_COMMAND,
  TableCellNode,
  TableCellResizer,
  TableNode,
  TablePlugin,
  TableRowNode,
  useLexicalComposer,
} from '@gridigor/vue-lexical'

const editor = useLexicalComposer()
// Add TableNode, TableRowNode, and TableCellNode to initialConfig.nodes.
</script>

<template>
  <button
    @click="
      editor.dispatchCommand(INSERT_TABLE_COMMAND, {
        columns: '3',
        includeHeaders: true,
        rows: '3',
      })
    "
  >
    Insert table
  </button>
  <TablePlugin :has-horizontal-scroll="true" />
  <TableCellResizer />
</template>

TableCellResizer is a Vue port of the resizer used by the official Lexical Playground. It adds Teleported row and column handles, initializes column widths, enforces practical minimum dimensions, and disappears in read-only mode. It is an extra playground-level component; @lexical/react itself does not export a resizer.

TableOfContentsPlugin tracks HeadingNode instances in document order. Its default slot receives { tableOfContents, editor }, where each entry is a [nodeKey, text, headingTag] tuple.

Collaboration

CollaborationPlugin connects a composer to a Yjs document and a provider. Put the collaborating composers under LexicalCollaboration, set the composer's initial editorState to null, and let one client bootstrap an empty document. The provider decides how Yjs updates travel between clients; for example, with y-websocket:

npm install yjs y-websocket
<script setup lang="ts">
import { WebsocketProvider } from 'y-websocket'
import { Doc } from 'yjs'
import {
  CollaborationPlugin,
  ContentEditable,
  LexicalCollaboration,
  LexicalComposer,
  RichTextPlugin,
} from '@gridigor/vue-lexical'

const initialConfig = {
  namespace: 'CollaborativeEditor',
  editorState: null,
  onError(error: Error) {
    throw error
  },
}

function providerFactory(id: string, docMap: Map<string, Doc>) {
  const doc = docMap.get(id) ?? new Doc()
  docMap.set(id, doc)
  return new WebsocketProvider('wss://your-yjs-server.example', id, doc)
}
</script>

<template>
  <LexicalCollaboration>
    <LexicalComposer :initial-config="initialConfig">
      <RichTextPlugin>
        <template #contentEditable>
          <ContentEditable aria-label="Collaborative editor" />
        </template>
      </RichTextPlugin>
      <CollaborationPlugin
        id="document-id"
        :provider-factory="providerFactory"
        :should-bootstrap="true"
        username="Ada"
        cursor-color="#7c3aed"
      />
    </LexicalComposer>
  </LexicalCollaboration>
</template>

Do not mount HistoryPlugin in the same composer: collaboration registers a Yjs-backed undo manager and publishes the usual Lexical undo, redo, and availability commands. TOGGLE_CONNECT_COMMAND from @lexical/yjs lets an application explicitly enter offline mode and reconnect. Remote awareness, names, cursor colors, selections, reconnect cleanup, custom initial state, and multiple editors are handled by the plugin lifecycle.

Set root-name when the Lexical content must use a Yjs shared-type key other than the default root. The same prop is available on CollaborationPluginV2__EXPERIMENTAL, whose default key is root-v2.

CollaborationPluginV2__EXPERIMENTAL matches Lexical's newer binding when the application creates the Doc and provider itself. Keep both instances stable for the lifetime of the mounted plugin:

<script setup lang="ts">
import { WebsocketProvider } from 'y-websocket'
import { Doc } from 'yjs'
import { CollaborationPluginV2__EXPERIMENTAL } from '@gridigor/vue-lexical'

const doc = new Doc({ gc: false })
const provider = new WebsocketProvider('wss://your-yjs-server.example', 'document-id', doc)
</script>

<template>
  <CollaborationPluginV2__EXPERIMENTAL
    id="document-id"
    :doc="doc"
    :provider="provider"
    :__should-bootstrap-unsafe="true"
    username="Ada"
    cursor-color="#7c3aed"
  />
</template>

Mount it inside the same LexicalCollaboration and LexicalComposer structure shown above. The unsafe bootstrap flag should be enabled for only one client. Creating the document with gc: false also enables Lexical's experimental Yjs version-diff commands. This V2 API is experimental and may change with Lexical.

Extension API

LexicalExtensionComposer builds an editor from Lexical extensions. Keep the root extension stable at module scope so Vue renders do not rebuild the editor:

<script setup lang="ts">
import { HistoryExtension } from '@lexical/history'
import { RichTextExtension } from '@lexical/rich-text'
import { defineExtension } from 'lexical'
import {
  ExtensionComponent,
  LexicalExtensionComposer,
  TreeViewExtension,
} from '@gridigor/vue-lexical'

const editorExtension = defineExtension({
  name: 'MyExtensionEditor',
  namespace: 'MyExtensionEditor',
  dependencies: [RichTextExtension, HistoryExtension, TreeViewExtension],
  onError(error) {
    throw error
  },
})
</script>

<template>
  <LexicalExtensionComposer :extension="editorExtension">
    <ExtensionComponent :extension="TreeViewExtension" view-class-name="tree-view" />
  </LexicalExtensionComposer>
</template>

The composer renders a default ContentEditable; pass :content-editable="null" to place one elsewhere, or pass a stable render function for custom markup. LexicalExtensionEditorComposer hosts an existing extension-built editor without taking ownership of its dispose() lifecycle.

TreeViewExtension is the full Lexical devtools view: it prints the editor tree, selection and the ten most recent commands, can show exported DOM, and records editor states for time travel. Its treeTypeButtonClassName, timeTravelButtonClassName, timeTravelPanelButtonClassName, timeTravelPanelClassName, timeTravelPanelSliderClassName, and viewClassName options match @lexical/react; customPrintNode can customize the text printed for individual Lexical nodes. The same props are available on the standalone TreeView component when an editor is passed directly.

Vue-dependent extensions expose components and reactive signals through useExtensionComponent, useExtensionDependency, and useExtensionSignalValue. VuePluginHostExtension can mount those components into an editor created outside a Vue component tree, with optional Teleport targets and automatic cleanup when the editor is disposed.

Accessibility and focus management

Extension-built editors can use the framework-neutral extensions from @lexical/a11y through Vue callback refs. Add the required extension to the editor's extension tree, then call the matching composable in a descendant of LexicalExtensionComposer:

<script setup lang="ts">
import { useLexicalFocusManagerRef, useLexicalRovingTabIndexRef } from '@gridigor/vue-lexical'

const focusManagerRef = useLexicalFocusManagerRef({
  toolbarItemSelector: '[data-toolbar-item]',
})
const rovingRef = useLexicalRovingTabIndexRef()
</script>

<template>
  <div :ref="focusManagerRef" role="toolbar">
    <div :ref="rovingRef">
      <button type="button" data-toolbar-item>Bold</button>
      <button type="button" data-toolbar-item>Italic</button>
    </div>
  </div>
</template>

The root extension must declare FocusManagerExtension and RovingTabIndexExtension as dependencies. useLexicalFocusTrapRef supports a reactive active state, and useLexicalAriaLiveRegion announces through AriaLiveRegionExtension. The experimental useLexicalSlotRef mounts a named Lexical node slot into a Vue-rendered DOM element and restores it on cleanup.

Decorator nodes

DecoratorNode values can be Vue VNodes. The composer automatically teleports them into the DOM element owned by the Lexical node and cleans up their Vue component instances when the node or editor is removed.

import type { VueDecorator } from '@gridigor/vue-lexical'
import { DecoratorNode } from 'lexical'
import { h } from 'vue'
import MentionChip from './MentionChip.vue'

class MentionNode extends DecoratorNode<VueDecorator> {
  // Implement the standard Lexical node methods alongside decorate().
  decorate(): VueDecorator {
    return h(MentionChip, { name: this.getName() })
  }
}

Register the custom node in initialConfig.nodes, just as with a framework-free Lexical editor. Decorators remain inside the same Vue application context, so Vue provide/inject and component reactivity continue to work through the Teleport.

Nested editors

Use LexicalNestedComposer for an editor owned by another Lexical node, such as an image caption. Create the nested editor once in the owning node and pass it through initialEditor:

<script setup lang="ts">
import type { LexicalEditor } from 'lexical'
import {
  ContentEditable,
  HistoryPlugin,
  LexicalNestedComposer,
  PlainTextPlugin,
} from '@gridigor/vue-lexical'

defineProps<{ editor: LexicalEditor }>()
</script>

<template>
  <LexicalNestedComposer :initial-editor="editor">
    <PlainTextPlugin>
      <template #contentEditable>
        <ContentEditable aria-label="Image caption" />
      </template>
    </PlainTextPlugin>
    <HistoryPlugin />
  </LexicalNestedComposer>
</template>

The nested editor inherits its parent, theme, namespace when omitted, registered nodes when omitted, and editable state. Set skip-editable-listener when the nested editor must manage its editable state independently. To share undo/redo across parent and nested editors, pass the same HistoryState instance to both HistoryPlugin components through externalHistoryState. Both HistoryState and createEmptyHistoryState() are exported from @gridigor/vue-lexical and its LexicalHistoryPlugin subpath.

When collaboration is active, nested content waits for its Yjs subdocument by default. Set skip-collab-checks only when the nested editor is intentionally managed outside that lifecycle. The deprecated initialNodes prop is retained for @lexical/react@0.48.0 compatibility; new code should configure nodes in createEditor({ nodes, parentEditor }).

Error boundary

LexicalErrorBoundary isolates errors thrown by descendant Vue components. Its scoped fallback slot receives the normalized error, Vue's errorInfo, and a reset function that mounts the original subtree again:

<script setup lang="ts">
import { LexicalErrorBoundary } from '@gridigor/vue-lexical'

function reportError(error: Error, errorInfo: string) {
  console.error(error, errorInfo)
}
</script>

<template>
  <LexicalErrorBoundary @error="reportError">
    <EditorUi />

    <template #fallback="{ error, reset }">
      <p role="alert">{{ error.message }}</p>
      <button type="button" @click="reset">Try again</button>
    </template>
  </LexicalErrorBoundary>
</template>

Errors raised by Lexical editor updates are not consumed by this component; they continue to use the composer's initialConfig.onError handler.

PlainTextPlugin and RichTextPlugin render DecoratorNode output through LexicalErrorBoundary by default. Their errorBoundary prop accepts a custom Vue boundary component when an application needs different fallback behavior.

SSR and Nuxt

The built-in components can render on the server without window or document. Editor root attachment and plugin listeners are deferred until Vue mounts the client application, so a ClientOnly wrapper is not required for the built-in composer and plugins.

Lexical fills the editable DOM from its editor state after hydration. Custom plugins and decorator components must follow the same rule as the built-ins: access browser APIs and register DOM-dependent behavior in onMounted, then clean it up in onUnmounted.

A runnable Nuxt 4 application is available in the examples/nuxt directory. It renders the editor shell on the server and initializes the Lexical state during client hydration.

Vue SPA example

The standalone examples/vue Vite application builds its rich-text editor with LexicalExtensionComposer. It demonstrates core Lexical extensions, a custom character-count signal extension, extension components, an icon toolbar, and the Tree View extension:

npm install --prefix examples/vue
npm run dev --prefix examples/vue

Use this example for the client-rendered Vue SPA integration path. The Nuxt example remains the reference for SSR and hydration.

Development

npm install
npx playwright install chromium firefox webkit
npm run check
npm run coverage
npm run test:browser
npm run test:browser:chromium
npm run build
npm run check:bundle-size
npm run check:tree-shaking
npm run check:api-reference
npm run generate:api-reference
npm run typecheck --prefix examples/vue
npm run build --prefix examples/vue

Releases

Package releases are published from GitHub Releases through npm trusted publishing. The GitHub tag must equal the version in package.json, with an optional leading v (for example, v1.1.0). Prerelease versions are published under the next dist-tag; stable versions use latest.

The dependency ranges intentionally stay on one Lexical minor line. CI tests the exact resolved stack selected for the release, while the peer range cannot cross into a new Lexical minor without a compatibility audit.

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors