1<template>
2 <div class="flex flex-col">
3 <Editor
4 ref="editorRef"
5 v-model="internalContent"
6 :extensions="extensions"
7 v-bind="uploadFn ? { uploadFunction: uploadFn } : {}"
8 :placeholder="placeholder"
9 >
10 <template #default>
11 <EditorBubbleMenu :items="commentToolbar" />
12 <div
13 class="flex items-center overflow-x-auto rounded-t-4 border border-b-0 border-outline-gray-1 bg-surface-gray-1 px-1 py-1"
14 >
15 <div
16 v-if="showAttachments"
17 class="inline-flex items-center gap-1.5 pe-1"
18 >
19 <FileUploader private @success="addAttachment">
20 <template #default="{ openFileSelector, uploading }">
21 {{ syncUploadingState(uploading) }}
22 <button
23 class="flex rounded-4 p-1 text-ink-gray-8 transition-colors hover:bg-surface-gray-3 disabled:opacity-40"
24 :disabled="uploading"
25 @click="openFileSelector()"
26 >
27 <LoadingIndicator v-if="uploading" class="h-4 w-4" />
28 <AttachmentIcon
29 v-else
30 class="h-4 w-4"
31 style="stroke-width: 1.5 !important"
32 />
33 </button>
34 </template>
35 </FileUploader>
36 <div class="h-4 w-[2px] border-s ml-1" />
37 </div>
38 <EditorFixedMenu :items="fullToolbar" />
39 <EditorTableMenu />
40 </div>
41 <EditorContent :class="editorClass" />
42 </template>
43 </Editor>
44 <AttachmentList
45 v-if="showAttachments && attachments?.length"
46 class="mt-2"
47 :attachments="attachments ?? []"
48 @remove="removeAttachment"
49 />
50 </div>
51</template>
52
53<script setup lang="ts">
54import { AttachmentList } from "@/components";
55import {
56 buildEditorExtensions,
57 commentToolbar,
58 fullToolbar,
59} from "@/components/editor/config";
60import { AttachmentIcon } from "@/components/icons";
61import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
62import { isContentEmpty } from "@/utils";
63import { FileUploader, LoadingIndicator, type UploadedFile } from "frappe-ui";
64import {
65 Editor,
66 EditorBubbleMenu,
67 EditorContent,
68 EditorFixedMenu,
69 EditorTableMenu,
70} from "frappe-ui/editor";
71import { computed, nextTick, onMounted, ref, watch } from "vue";
72
73type UploadFunction = (file: File) => Promise<UploadedFile>;
74
75const props = withDefaults(
76 defineProps<{
77 placeholder?: string;
78 minHeight?: string;
79 maxHeight?: string;
80 extensions?: any[];
81 showSignature?: boolean;
82 showAttachments?: boolean;
83 uploadFn?: UploadFunction;
84 }>(),
85 {
86 placeholder: "",
87 minHeight: "min-h-[180px]",
88 maxHeight: "max-h-80",
89 extensions: () => [],
90 showSignature: false,
91 showAttachments: false,
92 }
93);
94
95const internalContent = defineModel<string>({ default: "" });
96
97const editorRef = ref<any>(null);
98const attachments = defineModel<UploadedFile[] | null>("attachments", {
99 default: () => [],
100});
101const isUploading = ref(false);
102
103const extensions = buildEditorExtensions({ extra: props.extensions });
104
105const contentClass = [
106 "!prose-sm max-w-full overflow-auto py-1.5 px-3",
107 "rounded-b-4 border border-outline-gray-1 bg-surface-base",
108 "placeholder-ink-gray-4 text-ink-gray-8 -mt-0.5",
109];
110
111const editorClass = computed(() => [
112 ...contentClass,
113 props.minHeight,
114 props.maxHeight,
115]);
116
117const userResource = getUserEmailInfo();
118
119function getDefaultContent(signature: string): string {
120 return `<p></p><p></p>${signature}`;
121}
122
123function applySignature(signature: string) {
124 if (!isContentEmpty(internalContent.value)) return;
125 const commands = editorRef.value?.editor?.commands;
126 if (commands) {
127 commands.setContent(signature);
128 setTimeout(() => {
129 editorRef.value?.editor?.commands?.focus("start");
130 }, 0);
131 } else {
132 internalContent.value = signature;
133 }
134}
135
136function addAttachment(file: UploadedFile) {
137 attachments.value = [...(attachments.value ?? []), file];
138}
139
140function removeAttachment(attachment: UploadedFile) {
141 attachments.value = (attachments.value ?? []).filter((a) => a !== attachment);
142}
143
144// FileUploader exposes `uploading` only via slot scope, so mirror it into a
145// ref that the parent can read through the exposed `isUploading`.
146function syncUploadingState(uploading: boolean): string {
147 isUploading.value = uploading;
148 return "";
149}
150
151function getContent(): string {
152 return editorRef.value?.editor?.getHTML() ?? "";
153}
154
155function isEmpty(): boolean {
156 return !editorRef.value?.editor?.state?.doc?.textContent?.trim()?.length;
157}
158
159function reset() {
160 const commands = editorRef.value?.editor?.commands;
161 if (!commands) {
162 internalContent.value = "";
163 return;
164 }
165 const data = userResource?.data as { email_signature?: string } | null;
166 const sig = data?.email_signature
167 ? getDefaultContent(data.email_signature)
168 : "";
169 commands.setContent(sig || "<p></p>");
170 internalContent.value = sig;
171 attachments.value = null;
172 setTimeout(() => {
173 editorRef.value?.editor?.commands?.focus("start");
174 }, 0);
175}
176
177if (props.showSignature) {
178 // Late arrival: resource resolves after mount
179 watch(
180 () => userResource.data,
181 (data: { email_signature?: string } | null) => {
182 if (!data?.email_signature) return;
183 applySignature(getDefaultContent(data.email_signature));
184 }
185 );
186}
187
188onMounted(() => {
189 if (!props.showSignature) return;
190
191 nextTick(() => {
192 const data = userResource.data as { email_signature?: string } | null;
193 if (data?.email_signature) {
194 applySignature(getDefaultContent(data.email_signature));
195 }
196 });
197});
198
199defineExpose({
200 getContent,
201 isEmpty,
202 reset,
203 editorRef,
204 isUploading,
205});
206</script>
207