feat: Implement rich text note composer

This commit is contained in:
Jesse Wierzbinski 2024-12-25 20:46:14 +01:00
parent e0e8db8d55
commit f0516cb58a
No known key found for this signature in database
22 changed files with 569 additions and 135 deletions

View file

@ -0,0 +1,84 @@
<template>
<EditorContent :editor="editor"
: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']" />
</template>
<script lang="ts" setup>
import Highlight from "@tiptap/extension-highlight";
import Link from "@tiptap/extension-link";
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 StarterKit from "@tiptap/starter-kit";
import { Editor, EditorContent } from "@tiptap/vue-3";
import suggestion from "./suggestion.ts";
const content = defineModel<string>("content");
const {
placeholder,
disabled,
mode = "rich",
} = defineProps<{
placeholder?: string;
mode?: "rich" | "plain";
disabled?: boolean;
}>();
const editor = new Editor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder,
}),
Highlight,
Link,
Subscript,
Superscript,
Underline,
Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion,
}),
],
content: content.value,
onUpdate: ({ editor }) => {
content.value = mode === "rich" ? editor.getHTML() : editor.getText();
},
autofocus: true,
editable: !disabled,
});
watchEffect(() => {
if (disabled) {
editor.setEditable(false);
} else {
editor.setEditable(true);
}
});
onUnmounted(() => {
editor.destroy();
});
</script>
<style module>
@import url("~/styles/content.css");
</style>
<style>
.tiptap p.is-editor-empty:first-child::before {
color: hsl(var(--muted-foreground));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap .mention {
@apply font-bold rounded-sm text-primary-foreground bg-primary px-1 py-0.5;
}
</style>

View file

@ -0,0 +1,80 @@
<template>
<Command class="rounded-lg border shadow-md min-w-[200px]" :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" />
<span v-render-emojis="user.value.emojis">{{ user.value.display_name }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>
<script setup lang="ts">
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "~/components/ui/command";
import Avatar from "../profiles/avatar.vue";
import type { UserData } from "./suggestion";
const { items, command } = defineProps<{
items: UserData[];
command: (value: MentionNodeAttrs) => void;
}>();
const selectedIndex = ref(0);
const onKeyDown = ({ event }: { event: Event }) => {
if (event instanceof KeyboardEvent) {
if (event.key === "ArrowDown") {
selectedIndex.value = (selectedIndex.value + 1) % items.length;
scrollIntoView(selectedIndex.value);
return true;
}
if (event.key === "ArrowUp") {
selectedIndex.value =
(selectedIndex.value - 1 + items.length) % items.length;
scrollIntoView(selectedIndex.value);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex.value);
return true;
}
}
};
const selectItem = (index: number) => {
const item = items[index];
if (item) {
command({
id: item.key,
label: item.value.acct,
});
}
};
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

@ -0,0 +1,99 @@
import { VueRenderer } from "@tiptap/vue-3";
import tippy, { type Instance } from "tippy.js";
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
import type { SuggestionOptions } from "@tiptap/suggestion";
import type { Account } from "@versia/client/types";
import { go } from "fuzzysort";
import MentionList from "./mentions-list.vue";
export type UserData = {
key: string;
value: Account;
};
export default {
items: async ({ query }) => {
if (query.length === 0) {
return [];
}
const users = await client.value.searchAccount(query, { limit: 20 });
return go(
query,
users.data
// Deduplicate users
.filter(
(user, index, self) =>
self.findIndex((u) => u.acct === user.acct) === index,
)
.map((user) => ({
key: user.acct,
value: user,
})),
{ key: "key" },
)
.map((result) => ({
key: result.obj.key,
value: result.obj.value,
}))
.slice(0, 20);
},
render: () => {
let component: VueRenderer;
let popup: Instance[] & Instance;
return {
onStart: (props) => {
component = new VueRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
// @ts-expect-error Tippy types are wrong
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0]?.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup[0]?.hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0]?.destroy();
component.destroy();
},
};
},
} as Omit<SuggestionOptions<UserData, MentionNodeAttrs>, "editor">;