mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
feat: ✨ Implement rich text note composer
This commit is contained in:
parent
e0e8db8d55
commit
f0516cb58a
22 changed files with 569 additions and 135 deletions
84
components/editor/content.vue
Normal file
84
components/editor/content.vue
Normal 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>
|
||||
80
components/editor/mentions-list.vue
Normal file
80
components/editor/mentions-list.vue
Normal 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>
|
||||
99
components/editor/suggestion.ts
Normal file
99
components/editor/suggestion.ts
Normal 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">;
|
||||
Loading…
Add table
Add a link
Reference in a new issue