feat: ⬆️ Upgrade to Tiptap v3, add emoji suggestion field

This commit is contained in:
Jesse Wierzbinski 2025-07-12 21:17:14 +02:00
parent a0b01193d5
commit e879189c6a
9 changed files with 280 additions and 106 deletions

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { BubbleMenu, type Editor } from "@tiptap/vue-3";
import type { Editor } from "@tiptap/vue-3";
import { BubbleMenu } from "@tiptap/vue-3/menus";
import {
BoldIcon,
CurlyBracesIcon,

View file

@ -2,22 +2,21 @@
<BubbleMenu :editor="editor" />
<EditorContent :editor="editor"
v-bind="$attrs"
:class="[$style.content, 'prose prose-sm dark:prose-invert break-words prose-a:no-underline prose-a:hover:underline prose-p:first-of-type:mt-0']" />
:class="[$style.content, 'relative prose prose-sm dark:prose-invert break-words prose-a:no-underline prose-a:hover:underline prose-p:first-of-type:mt-0']" />
</template>
<script lang="ts" setup>
import Emoji, { emojis } from "@tiptap/extension-emoji";
import Highlight from "@tiptap/extension-highlight";
import Link from "@tiptap/extension-link";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import Underline from "@tiptap/extension-underline";
import { Placeholder } from "@tiptap/extensions";
import StarterKit from "@tiptap/starter-kit";
import { Editor, EditorContent } from "@tiptap/vue-3";
import BubbleMenu from "./bubble-menu.vue";
import suggestion from "./suggestion.ts";
import { emojiSuggestion, mentionSuggestion } from "./suggestion.ts";
const content = defineModel<string>("content");
const rawContent = defineModel<string>("rawContent");
@ -42,15 +41,15 @@ const editor = new Editor({
placeholder,
}),
Highlight,
Link,
Subscript,
Superscript,
Underline,
TaskList,
TaskItem,
Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion,
suggestion: mentionSuggestion,
}),
Emoji.configure({
emojis: emojis.concat(
@ -65,6 +64,7 @@ const editor = new Editor({
HTMLAttributes: {
class: "emoji not-prose",
},
suggestion: emojiSuggestion,
}),
],
content: content.value,

View file

@ -0,0 +1,88 @@
<template>
<Command class="rounded border shadow-md min-w-[200px] h-fit not-prose" :selected-value="emojis[selectedIndex]?.id">
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup class="emojis-group" heading="Emojis">
<CommandItem :value="emoji.id" v-for="emoji, index in emojis" :key="emoji.id" @click="selectItem(index)" class="scroll-m-10">
<img class="h-[1lh] align-middle inline hover:scale-110 transition-transform duration-75 ease-in-out" :src="emoji.url" :title="emoji.shortcode" />
<span>{{ emoji.shortcode }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>
<script setup lang="ts">
import type {} from "@tiptap/extension-emoji";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "~/components/ui/command";
const { items, command } = defineProps<{
items: string[];
command: (value: { name: string }) => void;
}>();
const selectedIndex = ref(0);
const emojis = computed(() => {
return items
.map((item) => {
return identity.value?.emojis.find(
(emoji) => emoji.shortcode === item,
);
})
.filter((emoji) => emoji !== undefined);
});
const onKeyDown = ({ event }: { event: Event }) => {
if (event instanceof KeyboardEvent) {
if (event.key === "ArrowDown") {
selectedIndex.value =
(selectedIndex.value + 1) % emojis.value.length;
scrollIntoView(selectedIndex.value);
return true;
}
if (event.key === "ArrowUp") {
selectedIndex.value =
(selectedIndex.value - 1 + emojis.value.length) %
emojis.value.length;
scrollIntoView(selectedIndex.value);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex.value);
return true;
}
}
};
const selectItem = (index: number) => {
const item = emojis.value[index];
if (item) {
command({
name: item.shortcode,
});
}
};
const scrollIntoView = (index: number) => {
const usersGroup = document.getElementsByClassName("mentions-group")[0];
const item = usersGroup?.children[index];
if (item) {
item.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
};
defineExpose({ onKeyDown });
</script>

View file

@ -1,10 +1,10 @@
<template>
<Command class="rounded border shadow-md min-w-[200px]" :selected-value="items[selectedIndex]?.key">
<Command class="rounded border shadow-md min-w-[200px] h-fit not-prose" :selected-value="items[selectedIndex]?.key">
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup class="mentions-group" heading="Users">
<CommandItem :value="user.key" v-for="user, index in items" :key="user.key" @click="selectItem(index)" class="scroll-m-10">
<Avatar class="mr-2 size-4" :src="user.value.avatar" :name="user.value.display_name" />
<Avatar class="size-4" :src="user.value.avatar" :name="user.value.display_name" />
<span v-render-emojis="user.value.emojis">{{ user.value.display_name }}</span>
</CommandItem>
</CommandGroup>

View file

@ -1,10 +1,12 @@
import { computePosition, flip, shift } from "@floating-ui/dom";
import type { Editor } from "@tiptap/core";
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
import type { SuggestionOptions } from "@tiptap/suggestion";
import { VueRenderer } from "@tiptap/vue-3";
import type { Account } from "@versia/client/schemas";
import { posToDOMRect, VueRenderer } from "@tiptap/vue-3";
import type { Account, CustomEmoji } from "@versia/client/schemas";
import { go } from "fuzzysort";
import tippy, { type Instance } from "tippy.js";
import type { z } from "zod";
import EmojiList from "./emojis-list.vue";
import MentionList from "./mentions-list.vue";
export type UserData = {
@ -12,7 +14,29 @@ export type UserData = {
value: z.infer<typeof Account>;
};
export default {
const updatePosition = (editor: Editor, element: HTMLElement): void => {
const virtualElement = {
getBoundingClientRect: () =>
posToDOMRect(
editor.view,
editor.state.selection.from,
editor.state.selection.to,
),
};
computePosition(virtualElement, element, {
placement: "bottom-start",
strategy: "absolute",
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = "max-content";
element.style.position = strategy;
element.style.left = `${x}px`;
element.style.top = `${y}px`;
});
};
export const mentionSuggestion = {
items: async ({ query }) => {
if (query.length === 0) {
return [];
@ -43,7 +67,6 @@ export default {
render: () => {
let component: VueRenderer;
let popup: Instance[] & Instance;
return {
onStart: (props) => {
@ -52,20 +75,17 @@ export default {
editor: props.editor,
});
if (!props.clientRect) {
if (!props.clientRect || !component.element) {
return;
}
// @ts-expect-error Tippy types are wrong
popup = tippy(props.editor.options.element, {
getReferenceClientRect: props.clientRect,
appendTo: () => props.editor.options.element,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
(component.element as HTMLElement).style.position = "absolute";
props.editor.view.dom.parentElement?.appendChild(
component.element,
);
updatePosition(props.editor, component.element as HTMLElement);
},
onUpdate(props) {
@ -75,14 +95,12 @@ export default {
return;
}
popup.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
updatePosition(props.editor, component.element as HTMLElement);
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup.hide();
component.destroy();
return true;
}
@ -91,10 +109,81 @@ export default {
},
onExit() {
popup.hide();
popup.destroy();
component.element?.remove();
component.destroy();
},
};
},
} as Omit<SuggestionOptions<UserData, MentionNodeAttrs>, "editor">;
export const emojiSuggestion = {
items: ({ query }) => {
if (query.length === 0) {
return [];
}
const emojis = (identity.value as Identity).emojis;
return go(
query,
emojis
.filter((emoji) => emoji.shortcode.includes(query))
.map((emoji) => ({
key: emoji.shortcode,
value: emoji,
})),
{ key: "key" },
)
.map((result) => result.obj.key)
.slice(0, 20);
},
render: () => {
let component: VueRenderer;
return {
onStart: (props) => {
component = new VueRenderer(EmojiList, {
props,
editor: props.editor,
});
if (!props.clientRect || !component.element) {
return;
}
(component.element as HTMLElement).style.position = "absolute";
props.editor.view.dom.parentElement?.appendChild(
component.element,
);
updatePosition(props.editor, component.element as HTMLElement);
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
updatePosition(props.editor, component.element as HTMLElement);
},
onKeyDown(props) {
if (props.event.key === "Escape") {
component.destroy();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
component.element?.remove();
component.destroy();
},
};
},
} as Omit<SuggestionOptions<string>, "editor">;