Skip to content

Commit 1826050

Browse files
authored
Merge pull request #1135 from UnknownSean8/feat/rich-text-functionality
markdown tiptap feature
2 parents 2d8ba25 + ee23636 commit 1826050

File tree

10 files changed

+989
-48
lines changed

10 files changed

+989
-48
lines changed

frontend/components/SearchBar.vue

+12-3
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
</template>
9797

9898
<script setup lang="ts">
99-
import { useMagicKeys, whenever } from "@vueuse/core";
99+
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
100100
import { IconMap } from "~/types/icon-map";
101101
import { SearchBarLocation } from "~/types/location";
102102

@@ -113,16 +113,25 @@ const sidebar = useSidebar();
113113
const { isMacOS } = useDevice();
114114

115115
const input = ref();
116+
const activeElement = useActiveElement();
116117
const hotkeyIndicators = ref();
117118
const isInputFocused = ref(false);
119+
const notUsingEditor = computed(
120+
() => !activeElement.value?.classList.contains("tiptap")
121+
);
118122
const { slash } = useMagicKeys({
119123
passive: false,
120124
onEventFired(e) {
121-
if (e.key === "/" && e.type === "keydown") e.preventDefault();
125+
if (
126+
e.key === "/" &&
127+
e.type === "keydown" &&
128+
!activeElement.value?.classList.contains("tiptap")
129+
)
130+
e.preventDefault();
122131
},
123132
});
124133

125-
whenever(slash, () => {
134+
whenever(logicAnd(slash, notUsingEditor), () => {
126135
setTimeout(() => {
127136
if (input.value) {
128137
input.value.focus();

frontend/components/card/discussion/CardDiscussionInput.vue

+77-24
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
22
<template>
33
<div class="card-style flex w-full flex-col px-3 py-4 md:flex-row">
4-
<div class="flex-col space-y-3 pt-3 md:grow md:space-y-4 md:pl-2 md:pt-0">
4+
<div class="w-full flex-col space-y-3 pt-3 md:space-y-4 md:p-2 md:pt-0">
55
<div class="flex flex-col justify-between md:flex-row">
66
<div class="flex items-center justify-center space-x-2 md:space-x-4">
77
<div class="w-min md:w-min">
@@ -24,7 +24,7 @@
2424
</div>
2525
</div>
2626
<div
27-
class="mt-2 flex w-full items-center space-x-1 md:w-fit md:flex-row lg:space-x-3"
27+
class="mt-2 flex w-full items-center justify-center space-x-1 pt-3 md:w-fit md:flex-row md:pt-0 lg:space-x-3"
2828
>
2929
<Icon
3030
@click="at()"
@@ -82,25 +82,8 @@
8282
/>
8383
</div>
8484
</div>
85-
<div v-if="discussionInput.highRisk" class="w-full md:w-full">
86-
<textarea
87-
id="message"
88-
rows="4"
89-
class="focus-brand block w-full rounded-lg border border-action-red bg-layer-0 p-2.5 text-sm placeholder-action-red focus:border-none dark:border-action-red dark:text-primary-text dark:placeholder-action-red"
90-
:placeholder="
91-
$t('i18n.components.card_discussion_input.leave_comment_high_risk')
92-
"
93-
></textarea>
94-
</div>
95-
<div v-else class="w-full md:w-full">
96-
<textarea
97-
id="message"
98-
rows="4"
99-
class="focus-brand block w-full rounded-lg border border-section-div bg-layer-0 p-2.5 text-sm text-primary-text placeholder-distinct-text"
100-
:placeholder="
101-
$t('i18n.components.card_discussion_input.leave_comment')
102-
"
103-
></textarea>
85+
<div class="w-full md:w-full">
86+
<editor-content :editor="editor" />
10487
</div>
10588
<div class="flex items-center justify-between px-1">
10689
<p class="inline-flex items-center">
@@ -147,39 +130,109 @@
147130
<script setup lang="ts">
148131
import type { DiscussionInput } from "~/types/content/discussion";
149132
import { IconMap } from "~/types/icon-map";
133+
import { useEditor, EditorContent } from "@tiptap/vue-3";
134+
import Placeholder from "@tiptap/extension-placeholder";
135+
import StarterKit from "@tiptap/starter-kit";
136+
import Link from "@tiptap/extension-link";
137+
import Mention from "@tiptap/extension-mention";
138+
import Suggestion from "./Suggestion";
150139

151140
const showTooltip = ref(false);
152-
153-
defineProps<{
141+
const props = defineProps<{
154142
discussionInput: DiscussionInput;
155143
}>();
144+
const i18n = useI18n();
145+
146+
const editor = useEditor({
147+
extensions: [
148+
StarterKit,
149+
Placeholder.configure({
150+
placeholder: props.discussionInput.highRisk
151+
? i18n.t(
152+
"i18n.components.card_discussion_input.leave_comment_high_risk"
153+
)
154+
: i18n.t("i18n.components.card_discussion_input.leave_comment"),
155+
}),
156+
Link,
157+
Mention.configure({
158+
HTMLAttributes: {
159+
class:
160+
"hover:underline font-bold rounded-2xl box-decoration-clone px-1 py-0.5",
161+
},
162+
suggestion: Suggestion,
163+
}),
164+
],
165+
editorProps: {
166+
attributes: {
167+
class:
168+
"focus-brand block w-full max-w-full rounded-lg border border-section-div bg-layer-0 p-2.5 text-sm text-primary-text placeholder-distinct-text prose dark:prose-invert text-clip",
169+
},
170+
},
171+
});
156172

157173
const at = () => {
158174
console.log("click on at");
175+
editor.value?.chain().focus().insertContent(" @").run();
159176
};
160177
const heading = () => {
161178
console.log("click on heading");
179+
editor.value?.chain().focus().toggleHeading({ level: 1 }).run();
162180
};
163181
const bold = () => {
164182
console.log("click on bold");
183+
editor.value?.chain().focus().toggleBold().run();
165184
};
166185
const italic = () => {
167186
console.log("click on italic");
187+
editor.value?.chain().focus().toggleItalic().run();
168188
};
169189
const blockquote = () => {
170190
console.log("click on blockquote");
191+
editor.value?.chain().focus().toggleCodeBlock().run();
171192
};
172193
const link = () => {
173194
console.log("click on link");
195+
const previousUrl = editor.value?.getAttributes("link").href;
196+
const url = window.prompt("URL", previousUrl);
197+
198+
if (url === null) {
199+
return;
200+
}
201+
202+
if (url === "") {
203+
editor.value?.chain().focus().extendMarkRange("link").unsetLink().run();
204+
return;
205+
}
206+
207+
editor.value
208+
?.chain()
209+
.focus()
210+
.extendMarkRange("link")
211+
.setLink({ href: url })
212+
.run();
174213
};
175-
// There is as of now no plan to add in attachments.
214+
215+
// Note: There is as of now no plan to add in attachments.
176216
// const attach = () => {
177217
// console.log("click on attach");
178218
// };
219+
179220
const listul = () => {
180221
console.log("click on listul");
222+
editor.value?.chain().focus().toggleBulletList().run();
181223
};
182224
const listol = () => {
183225
console.log("click on listol");
226+
editor.value?.chain().focus().toggleOrderedList().run();
184227
};
185228
</script>
229+
230+
<style>
231+
.tiptap p.is-editor-empty:first-child::before {
232+
color: #adb5bd;
233+
content: attr(data-placeholder);
234+
float: left;
235+
height: 0;
236+
pointer-events: none;
237+
}
238+
</style>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
2+
<template>
3+
<div
4+
class="relative flex flex-col gap-0.5 overflow-auto rounded-lg border border-section-div bg-layer-2 p-2 text-primary-text shadow-md"
5+
>
6+
<template v-if="items.length">
7+
<button
8+
v-for="(item, index) in items"
9+
@click="selectItem(index)"
10+
:key="index"
11+
:class="{
12+
'is-selected rounded-lg bg-cta-orange/30': index === selectedIndex,
13+
}"
14+
class="hover:[&.is-selected]:bg-bg-cta-orange/50 [&.is-selected]:bg-bg-cta-orange/50 flex w-full items-center gap-1 rounded-lg bg-transparent text-left hover:bg-cta-orange/50"
15+
>
16+
<div class="px-1 py-0.5">
17+
{{ item }}
18+
</div>
19+
</button>
20+
</template>
21+
<div v-else class="item">No result</div>
22+
</div>
23+
</template>
24+
25+
<script>
26+
export default {
27+
props: {
28+
items: {
29+
type: Array,
30+
required: true,
31+
},
32+
33+
command: {
34+
type: Function,
35+
required: true,
36+
},
37+
},
38+
39+
data() {
40+
return {
41+
selectedIndex: 0,
42+
};
43+
},
44+
45+
watch: {
46+
items() {
47+
this.selectedIndex = 0;
48+
},
49+
},
50+
51+
methods: {
52+
onKeyDown({ event }) {
53+
if (event.key === "ArrowUp") {
54+
this.upHandler();
55+
return true;
56+
}
57+
58+
if (event.key === "ArrowDown") {
59+
this.downHandler();
60+
return true;
61+
}
62+
63+
if (event.key === "Enter" || event.key === "Tab") {
64+
this.selectHandler();
65+
return true;
66+
}
67+
68+
return false;
69+
},
70+
71+
upHandler() {
72+
this.selectedIndex =
73+
(this.selectedIndex + this.items.length - 1) % this.items.length;
74+
},
75+
76+
downHandler() {
77+
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
78+
},
79+
80+
selectHandler() {
81+
this.selectItem(this.selectedIndex);
82+
},
83+
84+
selectItem(index) {
85+
const item = this.items[index];
86+
87+
if (item) {
88+
this.command({ id: item });
89+
}
90+
},
91+
},
92+
};
93+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { VueRenderer } from "@tiptap/vue-3";
2+
import tippy from "tippy.js";
3+
4+
import MentionList from "./MentionList.vue";
5+
6+
export default {
7+
items: ({ query }) => {
8+
return ["Jay Doe", "Jane Doe", "John Doe"]
9+
.filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
10+
.slice(0, 5);
11+
},
12+
13+
render: () => {
14+
let component;
15+
let popup;
16+
17+
return {
18+
onStart: (props) => {
19+
component = new VueRenderer(MentionList, {
20+
props,
21+
editor: props.editor,
22+
});
23+
24+
if (!props.clientRect) {
25+
return;
26+
}
27+
28+
popup = tippy("body", {
29+
getReferenceClientRect: props.clientRect,
30+
appendTo: () => document.body,
31+
content: component.element,
32+
showOnCreate: true,
33+
interactive: true,
34+
trigger: "manual",
35+
placement: "bottom-start",
36+
});
37+
},
38+
39+
onUpdate(props) {
40+
component.updateProps(props);
41+
42+
if (!props.clientRect) {
43+
return;
44+
}
45+
46+
popup[0].setProps({
47+
getReferenceClientRect: props.clientRect,
48+
});
49+
},
50+
51+
onKeyDown(props) {
52+
if (props.event.key === "Escape") {
53+
popup[0].hide();
54+
55+
return true;
56+
}
57+
58+
return component.ref?.onKeyDown(props);
59+
},
60+
61+
onExit() {
62+
popup[0].destroy();
63+
component.destroy();
64+
},
65+
};
66+
},
67+
};

frontend/components/discussion/Discussion.vue

-9
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
22
<template>
33
<div class="space-y-6">
4-
<DiscussionHeader />
5-
<div v-if="discussionEntries" class="space-y-6">
6-
<CardDiscussionEntry
7-
v-for="discussionEntry in discussionEntries"
8-
:key="discussionEntry.id"
9-
:isPrivate="false"
10-
:discussionEntry="discussionEntry"
11-
/>
12-
</div>
134
<CardDiscussionInput
145
v-if="organizations && discussionInput"
156
:discussionInput="discussionInput"

frontend/components/sidebar/left/SidebarLeftSelector.vue

+4-7
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22
<template>
33
<MenuLinkWrapper :id="id" :to="routeUrl" :selected="selected">
44
<div
5-
class="relative z-0 flex w-full items-center text-left text-sm font-medium"
6-
:class="{
7-
'space-x-2': sidebar.collapsed == false || sidebar.collapsedSwitch,
8-
}"
5+
class="relative z-0 flex w-full items-center space-x-2 text-left text-sm font-medium"
96
>
107
<span class="pl-1">
118
<Icon v-if="iconUrl" class="h-5 w-5 flex-shrink-0" :name="iconUrl" />
@@ -18,9 +15,9 @@
1815
<span class="sr-only">{{ $t("i18n._global.navigate_to") }}</span>
1916
{{ $t(label) }}
2017
</p>
21-
<span v-else class="sr-only"
22-
>{{ $t("i18n._global.navigate_to") }} {{ $t(label) }}</span
23-
>
18+
<span v-else class="sr-only">
19+
{{ $t("i18n._global.navigate_to") }} {{ $t(label) }}
20+
</span>
2421
</Transition>
2522
</div>
2623
</MenuLinkWrapper>

0 commit comments

Comments
 (0)