refactor editor
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user