625 lines
16 KiB
Vue
625 lines
16 KiB
Vue
<template>
|
|
<div class="tp-editor">
|
|
<tiptap-toolbar
|
|
:editor="editor || undefined"
|
|
:padding-t-b="articlePadding.paddingTB"
|
|
:padding-l-r="articlePadding.paddingLR"
|
|
:show-padding-tool="showPaddingTool"
|
|
:source-code-mode="sourceCodeMode"
|
|
:upload-image="handleInsertImage"
|
|
:upload-video="handleInsertVideo"
|
|
@update:padding-t-b="articlePadding.paddingTB = $event"
|
|
@update:padding-l-r="articlePadding.paddingLR = $event"
|
|
@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="editor" 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"
|
|
:style="editorBodyStyle"
|
|
:editor="editor"
|
|
: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, TiptapEditorInstance } 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;
|
|
paddingTB?: number;
|
|
paddingLR?: number;
|
|
showPaddingTool?: boolean;
|
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
|
}>(), {
|
|
modelValue: "",
|
|
placeholder: "请输入内容",
|
|
paddingTB: 1,
|
|
paddingLR: 1.25,
|
|
showPaddingTool: true
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: string];
|
|
"change": [value: string];
|
|
"update:paddingTB": [value: number];
|
|
"update:paddingLR": [value: number];
|
|
}>();
|
|
|
|
const { EditorContent, articlePadding, editor, editorBodyStyle } = useTiptapEditor(
|
|
props,
|
|
emit,
|
|
(event, value) => {
|
|
if (event === "update:paddingTB") {
|
|
emit("update:paddingTB", value);
|
|
return;
|
|
}
|
|
emit("update:paddingLR", 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%;
|
|
border: 1px solid var(--td-component-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.content {
|
|
flex: 1;
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: text;
|
|
padding: 2rem 10rem;
|
|
display: flex;
|
|
overflow: auto;
|
|
box-sizing: border-box;
|
|
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);
|
|
box-shadow: var(--td-shadow-1);
|
|
border-radius: var(--td-radius-default);
|
|
|
|
:deep(.editor-body) {
|
|
outline: none;
|
|
padding: var(--tp-editor-padding-tb) var(--tp-editor-padding-lr);
|
|
min-height: 100%;
|
|
box-sizing: border-box;
|
|
|
|
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: 100%;
|
|
background: var(--td-bg-color-container);
|
|
border-radius: var(--td-radius-default);
|
|
box-shadow: var(--td-shadow-1);
|
|
}
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 48rem) {
|
|
.tp-editor {
|
|
.content {
|
|
padding: 0;
|
|
}
|
|
}
|
|
}
|
|
</style>
|