Tiptap Conventions

SkillDev tools

Esposter Tiptap/ProseMirror conventions — suggestion extensions, plugin key uniqueness, VueRenderer v-show rule, and SuggestionTrigger enum. Apply when writing or modifying Tiptap extensions, suggestion lists, or editor composables.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Tiptap Conventions skill

What this skill tells your AI

The instructions your AI receives, as published by esposter/esposter in .agents/skills/tiptap/SKILL.md and read by ahel’s review.

Suggestion Extensions

Three suggestion extensions exist in the message input: emoji, mention, and slash command. Each follows the same pattern.

Files per suggestion feature

Paths differ per feature — there is no single {feature} folder convention:

FeatureExtension composableSuggestion config
Emojiapp/composables/message/editor/useEmojiExtension.tsapp/services/message/emoji/EmojiSuggestion.ts
Mentionapp/composables/message/mentions/useMentionExtension.tsapp/services/message/MentionSuggestion.ts (no subfolder)
Slash commandapp/composables/message/slashCommand/useSlashCommandExtension.tsapp/services/message/slashCommands/SlashCommandSuggestion.ts

List components are uniform: app/components/Message/Model/Message/Suggestion/{Feature}List.vue.

Unique PluginKey — required for every suggestion

ProseMirror throws "Adding different instances of a keyed plugin (suggestion$)" when multiple suggestion extensions share the same key. Every suggestion config must declare a unique PluginKey:

import { PluginKey } from "@tiptap/pm/state";

export const EmojiSuggestion: Except<SuggestionOptions<EmojiItem, EmojiItem>, "editor"> = {
  pluginKey: new PluginKey("emojiSuggestion"),
  char: SuggestionTrigger.Emoji,
  // ...
};

Named keys: "emojiSuggestion", "mentionSuggestion", "slashCommandSuggestion".

Custom extension boilerplate

const EmojiExtension = Extension.create({
  addOptions() {
    return { suggestion: {} };
  },
  addProseMirrorPlugins() {
    return [Suggestion({ editor: this.editor, ...this.options.suggestion })];
  },
  name: "emoji",
});

export const useEmojiExtension = () => EmojiExtension.configure({ suggestion: EmojiSuggestion });

Never inline extensions in components

new Extension(...), Extension.create(...), or addProseMirrorPlugins/new Plugin belong in a use*Extension composable under app/composables/message/ (see the table above for the per-feature folder), never in a .vue <script setup>. The composable pulls its own stores/session/refs (make it async + await if it awaits). A reactive value the plugin reads/writes (e.g. a cursor Ref for CSS v-bind) is passed in and stored via addOptions(), then mutated as this.options.x.value — avoids hijacking another extension's options with @ts-expect-error.

Exception: an extension wiring only a couple of local component callbacks (e.g. Editor.vue's Enter/Esc) may stay inline.

SuggestionTrigger enum

Trigger characters live in app/services/message/SuggestionTrigger.ts. Never hardcode "/", ":", or "@" as string literals in suggestion configs or component templates:

export enum SuggestionTrigger {
  Emoji = ":",
  Mention = "@",
  SlashCommand = "/",
}

Use in suggestion config (char: SuggestionTrigger.Emoji) and in templates ({{ SuggestionTrigger.Emoji }}{{ name }}{{ SuggestionTrigger.Emoji }}).

VueRenderer: always v-show on suggestion list root

Rule: Always use v-show on the suggestion list root element, never v-if.

<!-- WRONG -->
<div v-if="items.length > 0" ...></div>
<!-- CORRECT -->
<div v-show="items.length > 0" ...></div>

With v-if, VueRenderer.element returns a comment node when the condition is false; getRender.ts's onStart appends that comment to document.body, and the later-rendered real div lives in VueRenderer's internal container outside document.body — so the popup never appears. v-show keeps VueRenderer.element a real HTMLElement anchored in document.body for the whole session, so computePosition can always measure and reposition it.

Wiring extensions into the editor

In app/components/Message/Model/Message/Input/Index.vue, each extension is instantiated as a const and passed in the :extensions array:

const keyboardExtension = await useKeyboardShortcutsExtension();
const codeBlockExtension = useCodeBlockExtension();
const emojiExtension = useEmojiExtension();
const mentionExtension = useMentionExtension();
const slashCommandExtension = useSlashCommandExtension();
:extensions="[keyboardExtension, codeBlockExtension, emojiExtension, mentionExtension, slashCommandExtension]"

Every entry is a use*Extension() call. RichTextEditor owns only the always-on extensions (StarterKit, CharacterCount, Placeholder, FileHandler, useLinkClickExtension); feature extensions come via the :extensions prop.

useEditor owns the editor's lifecycle — never register a second teardown

useEditor constructs the editor in onMounted (so a component that awaits its content in setup is already seeded by then) and destroys it in its own onBeforeUnmount. A component adding an onBeforeUnmount of its own that calls editor.destroy() tears the same editor down twice — harmless today and a double-free the moment Tiptap's teardown stops being idempotent. Nothing about the editor's lifetime belongs in the calling component.

Signals

GitHub stars
23
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
tiptap-esposter
Source
github.com/esposter/esposter