Skip to content

Commit f0516cb

Browse files
committed
feat: ✨ Implement rich text note composer
1 parent e0e8db8 commit f0516cb

22 files changed

+569
-135
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ logs
2424
!.env.example
2525
config
2626

27-
public/emojis
27+
public/emojis
28+
29+
.npmrc

bun.lockb

29.4 KB
Binary file not shown.

components/composer/composer.vue

+15-15
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
<Input v-model:model-value="state.contentWarning" v-if="state.sensitive"
77
placeholder="Put your content warning here" />
88

9-
<Textarea id="text-input" :placeholder="chosenSplash" v-model:model-value="state.content"
10-
class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0"
11-
:disabled="sending" />
9+
<EditorContent v-model:content="state.content" :placeholder="chosenSplash"
10+
class="*:!border-none *:!ring-0 *:!outline-none *:rounded-none p-0 max-h-[50dvh] overflow-y-auto min-h-48 *:!ring-offset-0 *:h-full"
11+
:disabled="sending" :mode="state.contentType === 'text/html' ? 'rich' : 'plain'" />
1212

1313
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
1414
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
@@ -28,8 +28,11 @@
2828
</Tooltip>
2929
<Tooltip>
3030
<TooltipTrigger as="div">
31-
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'"
32-
@update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'">
31+
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/html'" @update:pressed="(i) =>
32+
(state.contentType = i
33+
? 'text/html'
34+
: 'text/plain')
35+
">
3336
<LetterText class="!size-5" />
3437
</Toggle>
3538
</TooltipTrigger>
@@ -87,7 +90,11 @@
8790
</Tooltip>
8891
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
8992
<Loader v-if="sending" class="!size-5 animate-spin" />
90-
{{ relation?.type === "edit" ? m.gaudy_strong_puma_slide() : m.free_teal_bulldog_learn() }}
93+
{{
94+
relation?.type === "edit"
95+
? m.gaudy_strong_puma_slide()
96+
: m.free_teal_bulldog_learn()
97+
}}
9198
</Button>
9299
</DialogFooter>
93100
</template>
@@ -112,9 +119,9 @@ import Note from "~/components/notes/note.vue";
112119
import { Select, SelectContent, SelectItem } from "~/components/ui/select";
113120
import * as m from "~/paraglide/messages.js";
114121
import { SettingIds } from "~/settings";
122+
import EditorContent from "../editor/content.vue";
115123
import { Button } from "../ui/button";
116124
import { Input } from "../ui/input";
117-
import { Textarea } from "../ui/textarea";
118125
import { Toggle } from "../ui/toggle";
119126
import Files from "./files.vue";
120127
@@ -124,13 +131,6 @@ const defaultVisibility = useSetting(SettingIds.DefaultVisibility);
124131
const { play } = useAudio();
125132
const fileInput = ref<HTMLInputElement | null>(null);
126133
127-
onMounted(() => {
128-
// Wait 0.3s for the dialog to open
129-
setTimeout(() => {
130-
document.getElementById("text-input")?.focus();
131-
}, 300);
132-
});
133-
134134
watch([Control_Enter, Command_Enter], () => {
135135
if (sending.value || !ctrlEnterSend.value.value) {
136136
return;
@@ -176,7 +176,7 @@ const state = reactive({
176176
content: relation?.source?.text || getMentions(),
177177
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
178178
contentWarning: relation?.type === "edit" ? relation.note.spoiler_text : "",
179-
contentType: "text/markdown" as "text/markdown" | "text/plain",
179+
contentType: "text/html" as "text/html" | "text/plain",
180180
visibility: (relation?.type === "edit"
181181
? relation.note.visibility
182182
: (defaultVisibility.value.value ?? "public")) as Status["visibility"],

components/editor/content.vue

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<template>
2+
<EditorContent :editor="editor"
3+
: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']" />
4+
</template>
5+
6+
<script lang="ts" setup>
7+
import Highlight from "@tiptap/extension-highlight";
8+
import Link from "@tiptap/extension-link";
9+
import Mention from "@tiptap/extension-mention";
10+
import Placeholder from "@tiptap/extension-placeholder";
11+
import Subscript from "@tiptap/extension-subscript";
12+
import Superscript from "@tiptap/extension-superscript";
13+
import Underline from "@tiptap/extension-underline";
14+
import StarterKit from "@tiptap/starter-kit";
15+
import { Editor, EditorContent } from "@tiptap/vue-3";
16+
import suggestion from "./suggestion.ts";
17+
18+
const content = defineModel<string>("content");
19+
const {
20+
placeholder,
21+
disabled,
22+
mode = "rich",
23+
} = defineProps<{
24+
placeholder?: string;
25+
mode?: "rich" | "plain";
26+
disabled?: boolean;
27+
}>();
28+
29+
const editor = new Editor({
30+
extensions: [
31+
StarterKit,
32+
Placeholder.configure({
33+
placeholder,
34+
}),
35+
Highlight,
36+
Link,
37+
Subscript,
38+
Superscript,
39+
Underline,
40+
Mention.configure({
41+
HTMLAttributes: {
42+
class: "mention",
43+
},
44+
suggestion,
45+
}),
46+
],
47+
content: content.value,
48+
onUpdate: ({ editor }) => {
49+
content.value = mode === "rich" ? editor.getHTML() : editor.getText();
50+
},
51+
autofocus: true,
52+
editable: !disabled,
53+
});
54+
55+
watchEffect(() => {
56+
if (disabled) {
57+
editor.setEditable(false);
58+
} else {
59+
editor.setEditable(true);
60+
}
61+
});
62+
63+
onUnmounted(() => {
64+
editor.destroy();
65+
});
66+
</script>
67+
68+
<style module>
69+
@import url("~/styles/content.css");
70+
</style>
71+
72+
<style>
73+
.tiptap p.is-editor-empty:first-child::before {
74+
color: hsl(var(--muted-foreground));
75+
content: attr(data-placeholder);
76+
float: left;
77+
height: 0;
78+
pointer-events: none;
79+
}
80+
81+
.tiptap .mention {
82+
@apply font-bold rounded-sm text-primary-foreground bg-primary px-1 py-0.5;
83+
}
84+
</style>

components/editor/mentions-list.vue

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<template>
2+
<Command class="rounded-lg border shadow-md min-w-[200px]" :selected-value="items[selectedIndex]?.key">
3+
<CommandList>
4+
<CommandEmpty>No results found.</CommandEmpty>
5+
<CommandGroup class="mentions-group" heading="Users">
6+
<CommandItem :value="user.key" v-for="user, index in items" :key="user.key" @click="selectItem(index)" class="scroll-m-10">
7+
<Avatar class="mr-2 size-4" :src="user.value.avatar" :name="user.value.display_name" />
8+
<span v-render-emojis="user.value.emojis">{{ user.value.display_name }}</span>
9+
</CommandItem>
10+
</CommandGroup>
11+
</CommandList>
12+
</Command>
13+
</template>
14+
15+
<script setup lang="ts">
16+
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
17+
import {
18+
Command,
19+
CommandEmpty,
20+
CommandGroup,
21+
CommandItem,
22+
CommandList,
23+
} from "~/components/ui/command";
24+
import Avatar from "../profiles/avatar.vue";
25+
import type { UserData } from "./suggestion";
26+
27+
const { items, command } = defineProps<{
28+
items: UserData[];
29+
command: (value: MentionNodeAttrs) => void;
30+
}>();
31+
32+
const selectedIndex = ref(0);
33+
34+
const onKeyDown = ({ event }: { event: Event }) => {
35+
if (event instanceof KeyboardEvent) {
36+
if (event.key === "ArrowDown") {
37+
selectedIndex.value = (selectedIndex.value + 1) % items.length;
38+
scrollIntoView(selectedIndex.value);
39+
40+
return true;
41+
}
42+
if (event.key === "ArrowUp") {
43+
selectedIndex.value =
44+
(selectedIndex.value - 1 + items.length) % items.length;
45+
scrollIntoView(selectedIndex.value);
46+
47+
return true;
48+
}
49+
if (event.key === "Enter") {
50+
selectItem(selectedIndex.value);
51+
return true;
52+
}
53+
}
54+
};
55+
56+
const selectItem = (index: number) => {
57+
const item = items[index];
58+
59+
if (item) {
60+
command({
61+
id: item.key,
62+
label: item.value.acct,
63+
});
64+
}
65+
};
66+
67+
const scrollIntoView = (index: number) => {
68+
const usersGroup = document.getElementsByClassName("mentions-group")[0];
69+
const item = usersGroup?.children[index];
70+
71+
if (item) {
72+
item.scrollIntoView({
73+
behavior: "smooth",
74+
block: "nearest",
75+
});
76+
}
77+
};
78+
79+
defineExpose({ onKeyDown });
80+
</script>

components/editor/suggestion.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { VueRenderer } from "@tiptap/vue-3";
2+
import tippy, { type Instance } from "tippy.js";
3+
4+
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
5+
import type { SuggestionOptions } from "@tiptap/suggestion";
6+
import type { Account } from "@versia/client/types";
7+
import { go } from "fuzzysort";
8+
import MentionList from "./mentions-list.vue";
9+
10+
export type UserData = {
11+
key: string;
12+
value: Account;
13+
};
14+
15+
export default {
16+
items: async ({ query }) => {
17+
if (query.length === 0) {
18+
return [];
19+
}
20+
21+
const users = await client.value.searchAccount(query, { limit: 20 });
22+
23+
return go(
24+
query,
25+
users.data
26+
// Deduplicate users
27+
.filter(
28+
(user, index, self) =>
29+
self.findIndex((u) => u.acct === user.acct) === index,
30+
)
31+
.map((user) => ({
32+
key: user.acct,
33+
value: user,
34+
})),
35+
{ key: "key" },
36+
)
37+
.map((result) => ({
38+
key: result.obj.key,
39+
value: result.obj.value,
40+
}))
41+
.slice(0, 20);
42+
},
43+
44+
render: () => {
45+
let component: VueRenderer;
46+
let popup: Instance[] & Instance;
47+
48+
return {
49+
onStart: (props) => {
50+
component = new VueRenderer(MentionList, {
51+
props,
52+
editor: props.editor,
53+
});
54+
55+
if (!props.clientRect) {
56+
return;
57+
}
58+
59+
// @ts-expect-error Tippy types are wrong
60+
popup = tippy("body", {
61+
getReferenceClientRect: props.clientRect,
62+
appendTo: () => document.body,
63+
content: component.element,
64+
showOnCreate: true,
65+
interactive: true,
66+
trigger: "manual",
67+
placement: "bottom-start",
68+
});
69+
},
70+
71+
onUpdate(props) {
72+
component.updateProps(props);
73+
74+
if (!props.clientRect) {
75+
return;
76+
}
77+
78+
popup[0]?.setProps({
79+
getReferenceClientRect: props.clientRect as () => DOMRect,
80+
});
81+
},
82+
83+
onKeyDown(props) {
84+
if (props.event.key === "Escape") {
85+
popup[0]?.hide();
86+
87+
return true;
88+
}
89+
90+
return component.ref?.onKeyDown(props);
91+
},
92+
93+
onExit() {
94+
popup[0]?.destroy();
95+
component.destroy();
96+
},
97+
};
98+
},
99+
} as Omit<SuggestionOptions<UserData, MentionNodeAttrs>, "editor">;

0 commit comments

Comments
 (0)