Files
timi-tdesign-mobile/src/components/tiptap-editor/index.vue
T
2026-08-27 16:04:06 +08:00

604 lines
15 KiB
Vue

<template>
<div class="tp-editor">
<tiptap-toolbar
:editor="editorInstance"
:source-code-mode="sourceCodeMode"
:upload-image="handleInsertImage"
:upload-video="handleInsertVideo"
@toggle-source-code="toggleSourceCodeMode"
/>
<div class="content">
<code-editor
v-if="sourceCodeMode"
ref="sourceEditorRef"
v-model="sourceCode"
class="source-editor"
language="html"
placeholder="请输入 HTML 源码"
/>
<div v-else-if="editorInstance" class="value-wrap">
<editor-search-bar
ref="searchBarRef"
:visible="searchVisible"
:expanded="replaceExpanded"
:keyword="searchKeyword"
:replace-value="replaceKeyword"
:status-error="searchError"
:match-index="searchMatchIndex"
:match-count="searchMatchList.length"
:case-sensitive="searchOptions.caseSensitive"
:use-regex="searchOptions.useRegex"
@update:expanded="replaceExpanded = $event"
@update:keyword="searchKeyword = $event"
@update:replace-value="replaceKeyword = $event"
@update:case-sensitive="searchOptions.caseSensitive = $event"
@update:use-regex="searchOptions.useRegex = $event"
@previous="findPrevious"
@next="onSearchNext"
@replace="replaceCurrent"
@replace-all="replaceAll"
@close="closeSearch"
/>
<editor-content
class="value"
:editor="editorInstance"
:spellcheck="false"
@keydown.capture="onRichEditorKeydown"
/>
</div>
<t-empty v-else description="编辑器初始化中" />
</div>
</div>
</template>
<script lang="ts" setup>
import CodeEditor from "../code-editor/index.vue";
import EditorSearchBar from "../editor-search/index.vue";
import {
buildSearchRegex,
replaceMatchText,
type TextSearchOptions
} from "timi-web/editor";
import { Toolkit } from "timi-web";
import type { PendingMediaItem, UploadMediaType, UploadResult } from "timi-web/editor";
import TiptapToolbar from "./TiptapToolbar.vue";
import { useTiptapEditor } from "timi-web/editor";
import {
ATTACHMENT_ID_ATTR,
LOCAL_MEDIA_ID_PREFIX,
TEMP_FILE_ID_ATTR,
isLocalMediaId,
normalizeContent
} from "timi-web/editor";
defineOptions({
name: "TiptapEditor"
});
type EditorSearchBarExpose = {
focusKeyword: (selectAll?: boolean) => void;
focusReplace: (selectAll?: boolean) => void;
};
type CodeEditorExpose = {
openSearch: (showReplace: boolean) => void;
};
type RichTextMatch = {
from: number;
to: number;
text: string;
};
const props = withDefaults(defineProps<{
modelValue: string;
placeholder?: string;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
}>(), {
modelValue: "",
placeholder: "请输入内容"
});
const emit = defineEmits<{
"update:modelValue": [value: string];
"change": [value: string];
}>();
const { EditorContent, editor } = useTiptapEditor(props, emit);
const editorInstance = computed(() => editor.value);
const pendingMediaMap = reactive(new Map<string, PendingMediaItem>());
const sourceCodeMode = ref(false);
const sourceCode = ref("");
const sourceEditorRef = ref<CodeEditorExpose>();
const searchBarRef = ref<EditorSearchBarExpose>();
const searchVisible = ref(false);
const replaceExpanded = ref(false);
const searchKeyword = ref("");
const replaceKeyword = ref("");
const searchError = ref("");
const searchMatchIndex = ref(-1);
const searchMatchList = ref<RichTextMatch[]>([]);
const searchOptions = reactive<TextSearchOptions>({
caseSensitive: false,
useRegex: false
});
function buildLocalMediaId() {
return `${LOCAL_MEDIA_ID_PREFIX}${Toolkit.uuid()}`;
}
function revokeMediaUrl(url?: string) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function removePendingMedia(id: string) {
const item = pendingMediaMap.get(id);
if (!item) {
return;
}
revokeMediaUrl(item.url);
pendingMediaMap.delete(id);
}
function clearPendingMedia() {
Array.from(pendingMediaMap.keys()).forEach(removePendingMedia);
}
function collectMediaIds(content?: string) {
const rawContent = normalizeContent(content);
if (!rawContent) {
return new Set<string>();
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
return new Set(
mediaList
.map(item => item.getAttribute(ATTACHMENT_ID_ATTR) || item.getAttribute(TEMP_FILE_ID_ATTR) || "")
.filter(Boolean)
);
}
function syncPendingMedia(content?: string) {
const mediaIdSet = collectMediaIds(content);
Array.from(pendingMediaMap.keys()).forEach((id) => {
if (!mediaIdSet.has(id)) {
removePendingMedia(id);
}
});
}
async function handleInsertMedia(file: File, type: UploadMediaType): Promise<UploadResult> {
const id = buildLocalMediaId();
const url = URL.createObjectURL(file);
pendingMediaMap.set(id, {
id,
type,
name: file.name,
file,
url
});
return {
id,
url,
name: file.name
};
}
async function handleInsertImage(file: File) {
return handleInsertMedia(file, "image");
}
async function handleInsertVideo(file: File) {
return handleInsertMedia(file, "video");
}
async function submit() {
const instance = editor.value;
if (!instance) {
return normalizeContent(props.modelValue);
}
const rawContent = normalizeContent(instance.getHTML());
if (!rawContent) {
clearPendingMedia();
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
const uploadedMediaIdList: string[] = [];
for (const media of mediaList) {
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || media.getAttribute(TEMP_FILE_ID_ATTR) || "";
if (!isLocalMediaId(attachmentId)) {
continue;
}
const pendingMedia = pendingMediaMap.get(attachmentId);
if (!pendingMedia) {
continue;
}
const uploadFn = pendingMedia.type === "image" ? props.uploadImage : props.uploadVideo;
if (!uploadFn) {
throw new Error(`未配置${pendingMedia.type === "image" ? "图片" : "视频"}上传方法`);
}
const result = await uploadFn(pendingMedia.file);
media.setAttribute("src", result.url);
media.setAttribute(ATTACHMENT_ID_ATTR, result.id);
media.setAttribute(TEMP_FILE_ID_ATTR, result.id);
if (media.tagName.toLowerCase() === "img") {
media.setAttribute("alt", result.name || pendingMedia.name);
media.setAttribute("title", result.name || pendingMedia.name);
} else {
media.setAttribute("title", result.name || pendingMedia.name);
}
uploadedMediaIdList.push(attachmentId);
}
const nextContent = normalizeContent(doc.body.innerHTML);
if (nextContent !== rawContent) {
instance.commands.setContent(nextContent || "<p></p>");
}
uploadedMediaIdList.forEach(removePendingMedia);
syncPendingMedia(nextContent);
return nextContent;
}
function syncSourceCode(value?: string) {
sourceCode.value = value || "";
}
function onSourceCodeChange(value: string) {
sourceCode.value = value;
emit("change", value);
emit("update:modelValue", value);
}
function getSelectedEditorText() {
const instance = editor.value;
if (!instance) {
return "";
}
const { from, to } = instance.state.selection;
if (from === to) {
return "";
}
return instance.state.doc.textBetween(from, to, "\n", "\n");
}
function refreshSearchMatchState() {
const instance = editor.value;
if (!instance || sourceCodeMode.value) {
searchError.value = "";
searchMatchList.value = [];
searchMatchIndex.value = -1;
return;
}
const { regex, error } = buildSearchRegex(searchKeyword.value, searchOptions, true);
searchError.value = error;
if (!regex || error || !searchKeyword.value) {
searchMatchList.value = [];
searchMatchIndex.value = -1;
return;
}
const matchList: RichTextMatch[] = [];
instance.state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) {
return;
}
regex.lastIndex = 0;
let match = regex.exec(node.text);
while (match) {
if (!match[0]) {
searchError.value = "正则不能匹配空文本";
searchMatchList.value = [];
searchMatchIndex.value = -1;
return false;
}
matchList.push({
from: pos + match.index,
to: pos + match.index + match[0].length,
text: match[0]
});
match = regex.exec(node.text);
}
});
searchMatchList.value = matchList;
const { from, to } = instance.state.selection;
searchMatchIndex.value = matchList.findIndex((match) => match.from === from && match.to === to);
if (0 > searchMatchIndex.value && 0 < matchList.length) {
searchMatchIndex.value = 0;
}
}
function openSearch(showReplace: boolean) {
if (sourceCodeMode.value) {
sourceEditorRef.value?.openSearch(showReplace);
return;
}
searchVisible.value = true;
replaceExpanded.value = showReplace;
const selectedText = getSelectedEditorText();
if (selectedText) {
searchKeyword.value = selectedText;
}
refreshSearchMatchState();
nextTick(() => {
searchBarRef.value?.focusKeyword(true);
});
}
function closeSearch() {
searchVisible.value = false;
replaceExpanded.value = false;
editor.value?.commands.focus();
}
function selectMatch(match: RichTextMatch, matchIndex: number) {
const instance = editor.value;
if (!instance) {
return false;
}
instance.chain().focus().setTextSelection({ from: match.from, to: match.to }).scrollIntoView().run();
searchMatchIndex.value = matchIndex;
return true;
}
function findNext() {
const instance = editor.value;
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
return false;
}
const currentTo = instance.state.selection.to;
const nextIndex = searchMatchList.value.findIndex((match) => match.from >= currentTo);
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
}
function findPrevious() {
const instance = editor.value;
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
return false;
}
const currentFrom = instance.state.selection.from;
let targetIndex = searchMatchList.value.length - 1;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
if (searchMatchList.value[index].from < currentFrom) {
targetIndex = index;
break;
}
}
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
}
function replaceCurrent() {
const instance = editor.value;
if (!instance || !searchKeyword.value) {
return false;
}
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
match.from === instance.state.selection.from && match.to === instance.state.selection.to
));
if (0 > currentMatchIndex) {
if (!findNext()) {
return false;
}
currentMatchIndex = searchMatchIndex.value;
}
const currentMatch = searchMatchList.value[currentMatchIndex];
if (!currentMatch) {
return false;
}
const nextText = replaceMatchText(currentMatch.text, searchKeyword.value, replaceKeyword.value, searchOptions);
const transaction = instance.state.tr.insertText(nextText, currentMatch.from, currentMatch.to);
instance.view.dispatch(transaction);
refreshSearchMatchState();
nextTick(() => {
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
if (!nextMatch) {
instance.commands.focus();
return;
}
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
});
return true;
}
function replaceAll() {
const instance = editor.value;
if (!instance || !searchKeyword.value) {
return 0;
}
if (searchError.value || 1 > searchMatchList.value.length) {
refreshSearchMatchState();
return 0;
}
const replaceCount = searchMatchList.value.length;
const transaction = instance.state.tr;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
const match = searchMatchList.value[index];
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
transaction.insertText(nextText, match.from, match.to);
}
instance.view.dispatch(transaction);
refreshSearchMatchState();
nextTick(() => {
if (searchMatchList.value[0]) {
selectMatch(searchMatchList.value[0], 0);
return;
}
instance.commands.focus();
});
return replaceCount;
}
function onSearchNext(shiftKey: boolean) {
if (shiftKey) {
findPrevious();
return;
}
findNext();
}
function onRichEditorKeydown(event: KeyboardEvent) {
if (sourceCodeMode.value || event.altKey || !(event.ctrlKey || event.metaKey)) {
return;
}
const key = event.key.toLowerCase();
if ("f" === key) {
event.preventDefault();
openSearch(false);
return;
}
if ("h" === key) {
event.preventDefault();
openSearch(true);
}
}
function toggleSourceCodeMode() {
const nextMode = !sourceCodeMode.value;
closeSearch();
if (nextMode) {
syncSourceCode(normalizeContent(editor.value?.getHTML()) || props.modelValue);
sourceCodeMode.value = true;
return;
}
sourceCodeMode.value = false;
const nextContent = normalizeContent(sourceCode.value);
if (editor.value) {
editor.value.commands.setContent(nextContent || "<p></p>");
}
}
watch(() => props.modelValue, (value) => {
syncPendingMedia(value);
syncSourceCode(value);
}, { immediate: true });
watch(sourceCode, (value) => {
if (!sourceCodeMode.value) {
return;
}
onSourceCodeChange(value);
});
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex, sourceCodeMode], () => {
refreshSearchMatchState();
});
watch(() => editor.value?.state.selection.from, () => {
if (!searchVisible.value || sourceCodeMode.value) {
return;
}
refreshSearchMatchState();
});
onBeforeUnmount(() => {
clearPendingMedia();
});
defineExpose({
editor,
submit,
getPendingMediaCount: () => pendingMediaMap.size,
openSearch,
closeSearch,
findNext,
findPrevious,
replaceCurrent,
replaceAll
});
</script>
<style lang="less" scoped>
.tp-editor {
width: 100%;
height: 100%;
max-width: 100%;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-default);
overflow: hidden;
background: var(--td-bg-color-page);
.content {
flex: 1;
width: 100%;
height: 100%;
display: flex;
min-width: 0;
min-height: 0;
padding: .5rem;
overflow: auto;
box-sizing: border-box;
cursor: text;
background: var(--td-bg-color-page);
.value-wrap {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
position: relative;
}
.value {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
overflow: auto;
background: var(--td-bg-color-container);
border-radius: var(--td-radius-default);
box-sizing: border-box;
box-shadow: var(--td-shadow-1);
:deep(.editor-body) {
outline: none;
padding: 1rem .875rem;
min-height: 100%;
box-sizing: border-box;
overflow-wrap: anywhere;
pre[class*='language-'] > code {
padding: .25rem .5rem;
}
p.is-editor-empty:first-child:before {
height: 0;
float: left;
color: var(--td-text-color-placeholder);
content: attr(data-placeholder);
pointer-events: none;
}
iframe.tp-iframe {
margin-right: auto;
margin-left: auto;
}
}
}
.source-editor {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 20rem;
background: var(--td-bg-color-container);
border-radius: var(--td-radius-default);
box-shadow: var(--td-shadow-1);
}
}
}
</style>