290 lines
8.3 KiB
Vue
290 lines
8.3 KiB
Vue
<template>
|
|
<t-textarea
|
|
v-if="isTextContent"
|
|
:model-value="editorContent"
|
|
:autosize="textareaAutosize"
|
|
:placeholder="placeholder"
|
|
@update:model-value="onSimpleEditorCommit"
|
|
/>
|
|
<markdown-editor
|
|
v-else-if="isMarkdownContent"
|
|
:data="editorContent"
|
|
@update:data="onSimpleEditorCommit"
|
|
/>
|
|
<tiptap-editor
|
|
v-else
|
|
ref="tiptapEditorRef"
|
|
:model-value="editorContent"
|
|
:placeholder="placeholder"
|
|
:upload-image="handleUploadImage"
|
|
:upload-video="handleUploadVideo"
|
|
@change="onEditorChange"
|
|
@update:model-value="onEditorCommit"
|
|
/>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import type { Article, ArticleContentType, Attachment, UploadResult } from "timi-web/editor";
|
|
import { AttachmentAPI } from "timi-web/editor";
|
|
import { MarkdownEditor } from "timi-web";
|
|
import { TiptapEditor } from "../tiptap-editor";
|
|
import type { TiptapEditorExpose } from "../tiptap-editor";
|
|
import {
|
|
ATTACHMENT_ID_ATTR,
|
|
TEMP_FILE_ID_ATTR,
|
|
buildAttachmentListFromContent,
|
|
getAttachmentIdFromSrc,
|
|
getTempFileIdFromSrc,
|
|
normalizeArticleContentType,
|
|
normalizeContent,
|
|
normalizeContentForStore,
|
|
normalizeRawArticleContent,
|
|
sameAttachmentList
|
|
} from "timi-web/editor";
|
|
|
|
defineOptions({
|
|
name: "ArticleEditor"
|
|
});
|
|
|
|
const props = withDefaults(defineProps<{
|
|
modelValue?: Article;
|
|
placeholder?: string;
|
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
|
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
|
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
|
|
}>(), {
|
|
modelValue: () => ({}),
|
|
placeholder: "请输入文章正文内容"
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
"change": [value: Article];
|
|
"update:modelValue": [value: Article];
|
|
}>();
|
|
|
|
const textareaAutosize = {
|
|
minRows: 8,
|
|
maxRows: 32
|
|
};
|
|
|
|
const editorContent = ref("");
|
|
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
|
const lastOutgoingContent = ref("");
|
|
const lastOutgoingAttachmentList = ref<Attachment[]>([]);
|
|
|
|
const contentType = computed(() => {
|
|
return normalizeArticleContentType(props.modelValue?.contentType);
|
|
});
|
|
const isTextContent = computed(() => contentType.value === "TEXT");
|
|
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
|
|
|
const attachmentMap = computed(() => {
|
|
return new Map((props.modelValue?.attachmentList || []).map(item => [item.id, item]));
|
|
});
|
|
|
|
async function defaultUploadImage(file: File) {
|
|
return defaultUploadMedia(file);
|
|
}
|
|
|
|
async function defaultUploadVideo(file: File) {
|
|
return defaultUploadMedia(file);
|
|
}
|
|
|
|
async function defaultUploadMedia(file: File) {
|
|
const tempFileList = await AttachmentAPI.upload([file]);
|
|
const tempFileId = tempFileList[0]?.id || "";
|
|
if (!tempFileId) {
|
|
throw new Error("上传临时文件失败");
|
|
}
|
|
return {
|
|
id: tempFileId,
|
|
url: AttachmentAPI.getTempReadURL(tempFileId),
|
|
name: file.name
|
|
};
|
|
}
|
|
|
|
async function handleUploadImage(file: File) {
|
|
if (props.uploadImage) {
|
|
return props.uploadImage(file);
|
|
}
|
|
return defaultUploadImage(file);
|
|
}
|
|
|
|
async function handleUploadVideo(file: File) {
|
|
if (props.uploadVideo) {
|
|
return props.uploadVideo(file);
|
|
}
|
|
return defaultUploadVideo(file);
|
|
}
|
|
|
|
async function resolveAttachmentPreviewUrl(attachmentId: string) {
|
|
const attachment = attachmentMap.value.get(attachmentId);
|
|
if (props.resolveAttachmentUrl) {
|
|
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
|
if (customUrl) {
|
|
return customUrl;
|
|
}
|
|
}
|
|
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
|
if (previewUrl) {
|
|
return previewUrl;
|
|
}
|
|
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
|
}
|
|
|
|
async function resolveTempPreviewUrl(tempFileId: string) {
|
|
if (props.resolveTempUrl) {
|
|
return props.resolveTempUrl(tempFileId);
|
|
}
|
|
return AttachmentAPI.getTempReadURL(tempFileId);
|
|
}
|
|
|
|
async function convertContentForEditor(value?: string) {
|
|
if (contentType.value !== "TIP_TAP") {
|
|
return normalizeRawArticleContent(value);
|
|
}
|
|
const rawContent = normalizeContent(value);
|
|
if (!rawContent) {
|
|
return "";
|
|
}
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(rawContent, "text/html");
|
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
|
for (const media of mediaList) {
|
|
const src = media.getAttribute("src") || "";
|
|
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
|
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
|
if (tempFileId) {
|
|
media.setAttribute(TEMP_FILE_ID_ATTR, tempFileId);
|
|
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId || tempFileId);
|
|
media.setAttribute("src", await resolveTempPreviewUrl(tempFileId));
|
|
continue;
|
|
}
|
|
if (!attachmentId) {
|
|
continue;
|
|
}
|
|
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId);
|
|
const previewUrl = await resolveAttachmentPreviewUrl(attachmentId);
|
|
if (previewUrl) {
|
|
media.setAttribute("src", previewUrl);
|
|
}
|
|
}
|
|
return normalizeContent(doc.body.innerHTML);
|
|
}
|
|
|
|
function normalizeIncomingArticleContent(content: string | undefined, currentContentType: ArticleContentType) {
|
|
if (currentContentType === "TIP_TAP") {
|
|
return normalizeContent(content);
|
|
}
|
|
return normalizeRawArticleContent(content);
|
|
}
|
|
|
|
function buildStoredContent(content: string, currentContentType: ArticleContentType) {
|
|
if (currentContentType === "TIP_TAP") {
|
|
return normalizeContentForStore(content);
|
|
}
|
|
return normalizeRawArticleContent(content);
|
|
}
|
|
|
|
function buildArticleAttachmentList(content: string, currentContentType: ArticleContentType) {
|
|
if (currentContentType === "TIP_TAP") {
|
|
return buildAttachmentListFromContent(content);
|
|
}
|
|
return props.modelValue?.attachmentList || [];
|
|
}
|
|
|
|
function buildArticleValue(content: string) {
|
|
const nextContentType = contentType.value;
|
|
return {
|
|
...(props.modelValue || {}),
|
|
contentType: nextContentType,
|
|
content: buildStoredContent(content, nextContentType),
|
|
attachmentList: buildArticleAttachmentList(content, nextContentType)
|
|
};
|
|
}
|
|
|
|
function syncLastOutgoingArticle(value: Article) {
|
|
const nextContentType = normalizeArticleContentType(value.contentType);
|
|
lastOutgoingContent.value = normalizeIncomingArticleContent(value.content, nextContentType);
|
|
lastOutgoingAttachmentList.value = value.attachmentList || [];
|
|
}
|
|
|
|
// 实时变更只给预览态使用,不能反向驱动编辑器提交态
|
|
function onEditorChange(value: string) {
|
|
const nextValue = buildArticleValue(value);
|
|
syncLastOutgoingArticle(nextValue);
|
|
emit("change", {
|
|
...nextValue
|
|
});
|
|
}
|
|
|
|
// 提交态只在离开代码块后的同步时机更新
|
|
function onEditorCommit(value: string) {
|
|
editorContent.value = value;
|
|
const nextValue = buildArticleValue(value);
|
|
syncLastOutgoingArticle(nextValue);
|
|
emit("update:modelValue", {
|
|
...nextValue
|
|
});
|
|
}
|
|
|
|
function onSimpleEditorCommit(value: string) {
|
|
editorContent.value = value;
|
|
const nextValue = buildArticleValue(value);
|
|
syncLastOutgoingArticle(nextValue);
|
|
emit("change", {
|
|
...nextValue
|
|
});
|
|
emit("update:modelValue", {
|
|
...nextValue
|
|
});
|
|
}
|
|
|
|
watch(() => [props.modelValue?.content, contentType.value] as const, async ([value, nextContentType]) => {
|
|
const nextValue = normalizeIncomingArticleContent(value, nextContentType);
|
|
if (nextValue === lastOutgoingContent.value) {
|
|
return;
|
|
}
|
|
if (buildStoredContent(editorContent.value, nextContentType) === nextValue) {
|
|
return;
|
|
}
|
|
const nextContent = await convertContentForEditor(nextValue);
|
|
if (nextContent === editorContent.value) {
|
|
return;
|
|
}
|
|
editorContent.value = nextContent;
|
|
}, { immediate: true });
|
|
|
|
watch(() => props.modelValue?.attachmentList, async (value) => {
|
|
if (contentType.value !== "TIP_TAP") {
|
|
return;
|
|
}
|
|
if (sameAttachmentList(lastOutgoingAttachmentList.value, value)) {
|
|
return;
|
|
}
|
|
if (sameAttachmentList(buildAttachmentListFromContent(editorContent.value), value)) {
|
|
return;
|
|
}
|
|
const nextContent = await convertContentForEditor(props.modelValue?.content);
|
|
if (nextContent === editorContent.value) {
|
|
return;
|
|
}
|
|
editorContent.value = nextContent;
|
|
}, { deep: true });
|
|
|
|
async function submit() {
|
|
const nextEditorContent = contentType.value === "TIP_TAP" && tiptapEditorRef.value ? await tiptapEditorRef.value.submit() : editorContent.value;
|
|
const nextValue = buildArticleValue(nextEditorContent);
|
|
editorContent.value = nextEditorContent;
|
|
syncLastOutgoingArticle(nextValue);
|
|
emit("update:modelValue", nextValue);
|
|
return nextValue;
|
|
}
|
|
|
|
defineExpose({
|
|
submit,
|
|
getPendingMediaCount: () => tiptapEditorRef.value?.getPendingMediaCount() || 0
|
|
});
|
|
</script>
|