Tiptap Conventions
SkillDev toolsEsposter 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.
No other account needed.
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:
| Feature | Extension composable | Suggestion config |
|---|---|---|
| Emoji | app/composables/message/editor/useEmojiExtension.ts | app/services/message/emoji/EmojiSuggestion.ts |
| Mention | app/composables/message/mentions/useMentionExtension.ts | app/services/message/MentionSuggestion.ts (no subfolder) |
| Slash command | app/composables/message/slashCommand/useSlashCommandExtension.ts | app/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