refactor editor
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 文章编辑器的公共数据处理。
|
||||
* 负责统一内容类型、附件标识和存储前的富文本格式,平台层不参与这些规则。
|
||||
*/
|
||||
import type { ArticleContentType, Attachment } from "../types";
|
||||
import { ATTACHMENT_ID_ATTR, isLocalMediaId, normalizeContent, TEMP_FILE_ID_ATTR } from "./tiptap/shared";
|
||||
|
||||
/** 文章内容中附件和临时文件的标识前缀。 */
|
||||
export const ATTACHMENT_ID_PREFIX = "attachment-id:";
|
||||
export const TEMP_FILE_ID_PREFIX = "temp-file-id:";
|
||||
export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE";
|
||||
export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO";
|
||||
/** 支持的文章内容类型和默认类型。 */
|
||||
export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP";
|
||||
export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP"];
|
||||
|
||||
/** 将后端历史值和未知值归一化为当前支持的内容类型。 */
|
||||
export function normalizeArticleContentType(contentType?: string): ArticleContentType {
|
||||
return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE;
|
||||
}
|
||||
|
||||
/** 将空内容转换为空字符串,避免编辑器收到 undefined。 */
|
||||
export function normalizeRawArticleContent(content?: string) {
|
||||
return content || "";
|
||||
}
|
||||
|
||||
/** 从媒体 src 中读取持久化附件 ID。 */
|
||||
export function getAttachmentIdFromSrc(src?: string) {
|
||||
if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) {
|
||||
return "";
|
||||
}
|
||||
return src.slice(ATTACHMENT_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
/** 从媒体 src 中读取临时文件 ID。 */
|
||||
export function getTempFileIdFromSrc(src?: string) {
|
||||
if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) {
|
||||
return "";
|
||||
}
|
||||
return src.slice(TEMP_FILE_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
/** 构造带有附件 ID 的内部媒体地址。 */
|
||||
export function buildAttachmentIdSrc(attachmentId: string) {
|
||||
return `${ATTACHMENT_ID_PREFIX}${attachmentId}`;
|
||||
}
|
||||
|
||||
/** 根据媒体标签判断附件类型。 */
|
||||
function getMediaAttachType(tagName: string) {
|
||||
return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE;
|
||||
}
|
||||
|
||||
/** 仅为 blob 预览地址保留预览元数据,服务器地址不重复保存。 */
|
||||
function buildAttachmentMetadata(src: string) {
|
||||
if (!src.startsWith("blob:")) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
previewUrl: src
|
||||
};
|
||||
}
|
||||
|
||||
/** 过滤本地临时媒体 ID,避免把前端占位 ID 当成可上传附件。 */
|
||||
function normalizeAttachmentTempFileId(tempFileId?: string) {
|
||||
if (!tempFileId || isLocalMediaId(tempFileId)) {
|
||||
return undefined;
|
||||
}
|
||||
return tempFileId;
|
||||
}
|
||||
|
||||
/** 从文章 HTML 中提取图片和视频附件,并按 ID 去重。 */
|
||||
export function buildAttachmentListFromContent(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
if (!rawContent) {
|
||||
return [] as Attachment[];
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
const attachmentMap = new Map<string, Attachment>();
|
||||
|
||||
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);
|
||||
const key = attachmentId || tempFileId;
|
||||
if (!key || attachmentMap.has(key)) {
|
||||
continue;
|
||||
}
|
||||
attachmentMap.set(key, {
|
||||
id: attachmentId || key,
|
||||
tempFileId: normalizeAttachmentTempFileId(tempFileId),
|
||||
name: media.getAttribute("title") || media.getAttribute("alt") || "",
|
||||
attachType: getMediaAttachType(media.tagName),
|
||||
metadata: buildAttachmentMetadata(src)
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(attachmentMap.values());
|
||||
}
|
||||
|
||||
/** 将媒体地址统一改写为附件地址,供后端保存文章内容。 */
|
||||
export function normalizeContentForStore(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
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);
|
||||
const finalAttachmentId = attachmentId || tempFileId;
|
||||
if (!finalAttachmentId) {
|
||||
continue;
|
||||
}
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, finalAttachmentId);
|
||||
media.removeAttribute(TEMP_FILE_ID_ATTR);
|
||||
media.setAttribute("src", buildAttachmentIdSrc(finalAttachmentId));
|
||||
}
|
||||
return normalizeContent(doc.body.innerHTML);
|
||||
}
|
||||
|
||||
/** 比较附件列表的业务字段,不比较对象引用。 */
|
||||
export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) {
|
||||
const leftList = left || [];
|
||||
const rightList = right || [];
|
||||
if (leftList.length !== rightList.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < leftList.length; i += 1) {
|
||||
const leftItem = leftList[i];
|
||||
const rightItem = rightList[i];
|
||||
if (!rightItem) {
|
||||
return false;
|
||||
}
|
||||
if (leftItem.id !== rightItem.id) {
|
||||
return false;
|
||||
}
|
||||
if ((leftItem.tempFileId || "") !== (rightItem.tempFileId || "")) {
|
||||
return false;
|
||||
}
|
||||
if ((leftItem.accessKey?.value || "") !== (rightItem.accessKey?.value || "")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/** 代码编辑器的公共能力,包括键盘输入处理和 Prism 高亮。 */
|
||||
import Prism from "prismjs";
|
||||
import { resolvePrismLanguage } from "../utils/Prism";
|
||||
|
||||
export { resolvePrismLanguage } from "../utils/Prism";
|
||||
|
||||
/** 编辑器文本选区,使用字符串下标表示半开区间。 */
|
||||
export type EditorSelection = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type EditorEditResult = EditorSelection & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
const TAB = "\t";
|
||||
const OPEN_CHAR_LIST = new Set(["(", "[", "{"]);
|
||||
const WRAP_CHAR_MAP: Record<string, string> = {
|
||||
"(": ")",
|
||||
"[": "]",
|
||||
"{": "}",
|
||||
"\"": "\"",
|
||||
"'": "'",
|
||||
"`": "`",
|
||||
"<": ">"
|
||||
};
|
||||
const CLOSE_CHAR_LIST = new Set(Object.values(WRAP_CHAR_MAP));
|
||||
|
||||
/** 根据键盘事件计算代码编辑器应执行的文本变更。 */
|
||||
export const resolveCodeEditorKeydown = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
event: KeyboardEvent
|
||||
): EditorEditResult | undefined => {
|
||||
if (event.isComposing || event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return undefined;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
return resolveTabInput(value, selection, event.shiftKey);
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
return resolveEnterInput(value, selection);
|
||||
}
|
||||
if (event.key === "Backspace") {
|
||||
return resolvePairBackspace(value, selection);
|
||||
}
|
||||
return resolvePairInput(value, selection, event.key);
|
||||
};
|
||||
|
||||
/** 使用 Prism 高亮代码;未识别语言时返回已转义的纯文本。 */
|
||||
export function highlightCode(value: string, language?: string | null) {
|
||||
const code = value || " ";
|
||||
const resolvedLanguage = resolvePrismLanguage(language);
|
||||
if (!resolvedLanguage) {
|
||||
return escapeHTML(code);
|
||||
}
|
||||
return Prism.highlight(code, Prism.languages[resolvedLanguage], resolvedLanguage);
|
||||
}
|
||||
|
||||
const resolveTabInput = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
shiftKey: boolean
|
||||
): EditorEditResult => {
|
||||
const { start, end } = selection;
|
||||
|
||||
if (!shiftKey && start === end) {
|
||||
return {
|
||||
value: value.slice(0, start) + TAB + value.slice(end),
|
||||
start: start + 1,
|
||||
end: start + 1
|
||||
};
|
||||
}
|
||||
|
||||
const lineStart = value.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
|
||||
const lineEndIndex = value.indexOf("\n", end);
|
||||
const blockEnd = lineEndIndex < 0 ? value.length : lineEndIndex;
|
||||
const selectedBlock = value.slice(lineStart, blockEnd);
|
||||
const lineList = selectedBlock.split("\n");
|
||||
|
||||
let nextStart = start;
|
||||
let nextEnd = end;
|
||||
const nextBlock = lineList.map((line, index) => {
|
||||
if (shiftKey) {
|
||||
if (line.startsWith(TAB)) {
|
||||
if (index === 0) {
|
||||
nextStart = Math.max(lineStart, nextStart - 1);
|
||||
}
|
||||
nextEnd = Math.max(nextStart, nextEnd - 1);
|
||||
return line.slice(1);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
if (index === 0) {
|
||||
nextStart += 1;
|
||||
}
|
||||
nextEnd += 1;
|
||||
return TAB + line;
|
||||
}).join("\n");
|
||||
|
||||
return {
|
||||
value: value.slice(0, lineStart) + nextBlock + value.slice(blockEnd),
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
};
|
||||
};
|
||||
|
||||
const resolveEnterInput = (
|
||||
value: string,
|
||||
selection: EditorSelection
|
||||
): EditorEditResult => {
|
||||
const { start, end } = selection;
|
||||
const lineStart = value.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
|
||||
const lineIndent = getLineIndent(value.slice(lineStart, start));
|
||||
const prevChar = value[start - 1] ?? "";
|
||||
const nextChar = value[end] ?? "";
|
||||
const isWrappedBlock = start === end
|
||||
&& OPEN_CHAR_LIST.has(prevChar)
|
||||
&& WRAP_CHAR_MAP[prevChar] === nextChar;
|
||||
|
||||
if (isWrappedBlock) {
|
||||
const nextValue = value.slice(0, start)
|
||||
+ `\n${lineIndent}${TAB}\n${lineIndent}`
|
||||
+ value.slice(end);
|
||||
const nextCursor = start + 1 + lineIndent.length + TAB.length;
|
||||
|
||||
return {
|
||||
value: nextValue,
|
||||
start: nextCursor,
|
||||
end: nextCursor
|
||||
};
|
||||
}
|
||||
|
||||
const extraIndent = OPEN_CHAR_LIST.has(prevChar) ? TAB : "";
|
||||
const nextValue = value.slice(0, start)
|
||||
+ `\n${lineIndent}${extraIndent}`
|
||||
+ value.slice(end);
|
||||
const nextCursor = start + 1 + lineIndent.length + extraIndent.length;
|
||||
|
||||
return {
|
||||
value: nextValue,
|
||||
start: nextCursor,
|
||||
end: nextCursor
|
||||
};
|
||||
};
|
||||
|
||||
const resolvePairInput = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
key: string
|
||||
): EditorEditResult | undefined => {
|
||||
const wrapChar = WRAP_CHAR_MAP[key];
|
||||
if (wrapChar) {
|
||||
return wrapSelection(value, selection, key, wrapChar);
|
||||
}
|
||||
|
||||
if (CLOSE_CHAR_LIST.has(key) && selection.start === selection.end && value[selection.start] === key) {
|
||||
return {
|
||||
value,
|
||||
start: selection.start + 1,
|
||||
end: selection.start + 1
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolvePairBackspace = (
|
||||
value: string,
|
||||
selection: EditorSelection
|
||||
): EditorEditResult | undefined => {
|
||||
const { start, end } = selection;
|
||||
if (start !== end || start < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevChar = value[start - 1];
|
||||
const nextChar = value[start];
|
||||
if (WRAP_CHAR_MAP[prevChar] !== nextChar) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: value.slice(0, start - 1) + value.slice(start + 1),
|
||||
start: start - 1,
|
||||
end: start - 1
|
||||
};
|
||||
};
|
||||
|
||||
const wrapSelection = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
openChar: string,
|
||||
closeChar: string
|
||||
): EditorEditResult => {
|
||||
const { start, end } = selection;
|
||||
const selectedText = value.slice(start, end);
|
||||
const nextValue = value.slice(0, start) + openChar + selectedText + closeChar + value.slice(end);
|
||||
|
||||
if (start === end) {
|
||||
return {
|
||||
value: nextValue,
|
||||
start: start + 1,
|
||||
end: start + 1
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: nextValue,
|
||||
start: start + 1,
|
||||
end: end + 1
|
||||
};
|
||||
};
|
||||
|
||||
const getLineIndent = (value: string) => {
|
||||
const match = value.match(/^[\t ]*/);
|
||||
|
||||
return match?.[0] ?? "";
|
||||
};
|
||||
|
||||
function escapeHTML(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 编辑器公共能力出口。平台组件只依赖这里的无界面逻辑和类型。 */
|
||||
export * from "./article";
|
||||
export * from "./code";
|
||||
export * from "./notify";
|
||||
export * from "./search";
|
||||
export * from "./tiptap/shared";
|
||||
export * from "./tiptap/extensions";
|
||||
export * from "./tiptap/useTiptapEditor";
|
||||
export * from "./tiptap/useTiptapView";
|
||||
export type { Article, ArticleContentType } from "../types/Article";
|
||||
export type { Attachment } from "../types/Attachment";
|
||||
export type { NotifyDetail } from "../types/Notify";
|
||||
export { default as AttachmentAPI } from "../api/AttachmentAPI";
|
||||
export { default as PrismCodeBlock } from "./tiptap/PrismCodeBlock";
|
||||
export { enhancePrismCodeBlock } from "./tiptap/PrismCodeBlockView";
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 通知编辑器的公共数据规则。 */
|
||||
import type { NotifyContentType } from "../types";
|
||||
|
||||
/** 通知缺少内容类型时默认使用 Tiptap。 */
|
||||
export const DEFAULT_NOTIFY_CONTENT_TYPE: NotifyContentType = "TIP_TAP";
|
||||
|
||||
/** 规范化通知内容类型,兼容旧通知未返回内容类型的情况。 */
|
||||
export function normalizeNotifyContentType(contentType?: string): NotifyContentType {
|
||||
return contentType === "TEXT" ? "TEXT" : DEFAULT_NOTIFY_CONTENT_TYPE;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/** 编辑器文本搜索、匹配和替换的无界面实现。 */
|
||||
export type TextSearchOptions = {
|
||||
caseSensitive: boolean;
|
||||
useRegex: boolean;
|
||||
};
|
||||
|
||||
/** 单个匹配项在原文本中的半开区间。 */
|
||||
export type TextSearchMatch = {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** 搜索结果;error 为空表示搜索过程正常。 */
|
||||
export type TextSearchResult = {
|
||||
matches: TextSearchMatch[];
|
||||
error: string;
|
||||
};
|
||||
|
||||
/** 根据搜索选项构造正则标志。 */
|
||||
const buildFlags = (options: TextSearchOptions, global = true) => {
|
||||
let flags = global ? "g" : "";
|
||||
if (!options.caseSensitive) {
|
||||
flags += "i";
|
||||
}
|
||||
return flags;
|
||||
};
|
||||
|
||||
/** 转义普通文本搜索中的正则特殊字符。 */
|
||||
export const escapeSearchKeyword = (value: string) => (
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
);
|
||||
|
||||
/** 构造搜索正则,并拒绝无效或可匹配空文本的表达式。 */
|
||||
export const buildSearchRegex = (
|
||||
keyword: string,
|
||||
options: TextSearchOptions,
|
||||
global = true
|
||||
) => {
|
||||
if (!keyword) {
|
||||
return { regex: undefined, error: "" };
|
||||
}
|
||||
try {
|
||||
const source = options.useRegex ? keyword : escapeSearchKeyword(keyword);
|
||||
const regex = new RegExp(source, buildFlags(options, global));
|
||||
const emptyMatch = "".match(regex);
|
||||
if (emptyMatch && emptyMatch[0] === "") {
|
||||
return {
|
||||
regex: undefined,
|
||||
error: "正则不能匹配空文本"
|
||||
};
|
||||
}
|
||||
return { regex, error: "" };
|
||||
} catch {
|
||||
return {
|
||||
regex: undefined,
|
||||
error: "正则表达式无效"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** 查找全部匹配项并返回文本位置。 */
|
||||
export const findTextMatches = (
|
||||
value: string,
|
||||
keyword: string,
|
||||
options: TextSearchOptions
|
||||
): TextSearchResult => {
|
||||
const { regex, error } = buildSearchRegex(keyword, options, true);
|
||||
if (!regex || error) {
|
||||
return {
|
||||
matches: [],
|
||||
error
|
||||
};
|
||||
}
|
||||
const matches: TextSearchMatch[] = [];
|
||||
let match = regex.exec(value);
|
||||
while (match) {
|
||||
if (!match[0]) {
|
||||
return {
|
||||
matches: [],
|
||||
error: "正则不能匹配空文本"
|
||||
};
|
||||
}
|
||||
matches.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
text: match[0]
|
||||
});
|
||||
match = regex.exec(value);
|
||||
}
|
||||
return {
|
||||
matches,
|
||||
error: ""
|
||||
};
|
||||
};
|
||||
|
||||
/** 替换单个匹配项;正则模式下使用捕获组替换规则。 */
|
||||
export const replaceMatchText = (
|
||||
matchText: string,
|
||||
keyword: string,
|
||||
replaceText: string,
|
||||
options: TextSearchOptions
|
||||
) => {
|
||||
if (!options.useRegex) {
|
||||
return replaceText;
|
||||
}
|
||||
const { regex, error } = buildSearchRegex(keyword, options, false);
|
||||
if (!regex || error) {
|
||||
return matchText;
|
||||
}
|
||||
return matchText.replace(regex, replaceText);
|
||||
};
|
||||
|
||||
/** 替换全部匹配项,并返回实际替换数量。 */
|
||||
export const replaceAllText = (
|
||||
value: string,
|
||||
keyword: string,
|
||||
replaceText: string,
|
||||
options: TextSearchOptions
|
||||
) => {
|
||||
const { matches, error } = findTextMatches(value, keyword, options);
|
||||
if (error || 1 > matches.length) {
|
||||
return {
|
||||
value,
|
||||
count: 0,
|
||||
error
|
||||
};
|
||||
}
|
||||
let nextValue = "";
|
||||
let lastEnd = 0;
|
||||
matches.forEach((match) => {
|
||||
nextValue += value.slice(lastEnd, match.start);
|
||||
nextValue += replaceMatchText(match.text, keyword, replaceText, options);
|
||||
lastEnd = match.end;
|
||||
});
|
||||
nextValue += value.slice(lastEnd);
|
||||
return {
|
||||
value: nextValue,
|
||||
count: matches.length,
|
||||
error: ""
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 带 Prism 装饰的 Tiptap 代码块。
|
||||
* ProseMirror 文档只保存纯文本,语法高亮通过 Decoration 叠加,避免污染编辑内容。
|
||||
*/
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
import { findChildren, mergeAttributes } from "@tiptap/vue-3";
|
||||
import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import type { EditorState, Transaction } from "@tiptap/pm/state";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import Prism from "prismjs";
|
||||
import { resolvePrismLanguage } from "../../utils/Prism";
|
||||
|
||||
/** Prism 分词后用于生成 Decoration 的扁平节点。 */
|
||||
type HighlightNode = {
|
||||
text: string;
|
||||
classes: string[];
|
||||
};
|
||||
|
||||
/** Prism Token 的递归结构。 */
|
||||
type PrismToken = {
|
||||
type: string;
|
||||
alias?: string | string[];
|
||||
content: string | PrismToken | Array<string | PrismToken>;
|
||||
};
|
||||
|
||||
/** 用于判断事务影响范围的最小结构。 */
|
||||
type StepRange = {
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
/** 将语言名称转换为代码块 class 名。 */
|
||||
function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) {
|
||||
if (!languageClassPrefix || !language) {
|
||||
return null;
|
||||
}
|
||||
return `${languageClassPrefix}${language}`;
|
||||
}
|
||||
|
||||
/** 生成 Prism token 对应的 class 列表。 */
|
||||
function buildTokenClassList(type: string, alias?: string | string[]) {
|
||||
const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : [];
|
||||
return ["token", type, ...aliasList];
|
||||
}
|
||||
|
||||
/** 将嵌套 Prism token 展平成文本片段,便于生成行内装饰。 */
|
||||
function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[] = []): HighlightNode[] {
|
||||
return tokens.flatMap((token) => {
|
||||
if (typeof token === "string") {
|
||||
return [{
|
||||
text: token,
|
||||
classes: className
|
||||
}];
|
||||
}
|
||||
|
||||
const classes = [...className, ...buildTokenClassList(token.type, token.alias)];
|
||||
if (typeof token.content === "string") {
|
||||
return [{
|
||||
text: token.content,
|
||||
classes
|
||||
}];
|
||||
}
|
||||
if (Array.isArray(token.content)) {
|
||||
return parsePrismTokens(token.content, classes);
|
||||
}
|
||||
return parsePrismTokens([token.content], classes);
|
||||
});
|
||||
}
|
||||
|
||||
/** 为文档中的全部代码块生成语法高亮装饰。 */
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
defaultLanguage
|
||||
}: {
|
||||
doc: ProsemirrorNode;
|
||||
name: string;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
findChildren(doc, node => node.type.name === name).forEach((block) => {
|
||||
let from = block.pos + 1;
|
||||
const prismLanguage = resolvePrismLanguage(block.node.attrs.language || defaultLanguage);
|
||||
const grammar = prismLanguage ? Prism.languages[prismLanguage] : null;
|
||||
const tokens = grammar ? Prism.tokenize(block.node.textContent, grammar) : [block.node.textContent];
|
||||
|
||||
parsePrismTokens(tokens).forEach((node) => {
|
||||
const to = from + node.text.length;
|
||||
if (node.classes.length) {
|
||||
decorations.push(Decoration.inline(from, to, {
|
||||
class: node.classes.join(" ")
|
||||
}));
|
||||
}
|
||||
from = to;
|
||||
});
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
/** 创建只在代码块内容或结构受影响时重新计算高亮的插件。 */
|
||||
function createPrismPlugin({
|
||||
name,
|
||||
defaultLanguage
|
||||
}: {
|
||||
name: string;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
const prismPlugin: Plugin<any> = new Plugin({
|
||||
key: new PluginKey("prism"),
|
||||
state: {
|
||||
init: (_: unknown, { doc }: EditorState) => getDecorations({
|
||||
doc,
|
||||
name,
|
||||
defaultLanguage
|
||||
}),
|
||||
apply: (transaction: Transaction, decorationSet: DecorationSet, oldState: EditorState, newState: EditorState) => {
|
||||
const oldNodeName = oldState.selection.$head.parent.type.name;
|
||||
const newNodeName = newState.selection.$head.parent.type.name;
|
||||
const oldNodes = findChildren(oldState.doc, node => node.type.name === name);
|
||||
const newNodes = findChildren(newState.doc, node => node.type.name === name);
|
||||
|
||||
if (
|
||||
transaction.docChanged
|
||||
&& (
|
||||
[oldNodeName, newNodeName].includes(name)
|
||||
|| newNodes.length !== oldNodes.length
|
||||
|| transaction.steps.some((step) => {
|
||||
const range = step as StepRange;
|
||||
return range.from !== undefined
|
||||
&& range.to !== undefined
|
||||
&& oldNodes.some(node => node.pos >= range.from! && node.pos + node.node.nodeSize <= range.to!);
|
||||
})
|
||||
)
|
||||
) {
|
||||
return getDecorations({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
defaultLanguage
|
||||
});
|
||||
}
|
||||
|
||||
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state: EditorState) {
|
||||
return prismPlugin.getState(state);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return prismPlugin;
|
||||
}
|
||||
|
||||
/** 自定义代码块渲染和 Prism 装饰插件。 */
|
||||
export default CodeBlock.extend({
|
||||
renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record<string, unknown> }) {
|
||||
const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language);
|
||||
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
class: languageClassName
|
||||
}),
|
||||
[
|
||||
"code",
|
||||
{
|
||||
class: languageClassName
|
||||
},
|
||||
0
|
||||
]
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
createPrismPlugin({
|
||||
name: this.name,
|
||||
defaultLanguage: this.options.defaultLanguage
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/** 为只读代码块补充行号、折叠和展开交互。 */
|
||||
export function enhancePrismCodeBlock(root: ParentNode) {
|
||||
const codeList = Array.from(root.querySelectorAll("pre > code[class*='language-'], pre[class*='language-'] > code"));
|
||||
|
||||
for (const code of codeList) {
|
||||
if (!(code instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pre = code.parentElement;
|
||||
if (!(pre instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
syncPreLanguageClass(pre, code);
|
||||
|
||||
resetCodeBlock(code);
|
||||
normalizeCodeContent(code);
|
||||
|
||||
const maxHeight = resolveCodeBlockMaxHeight(pre);
|
||||
if (maxHeight && maxHeight !== "auto") {
|
||||
pre.style.maxHeight = maxHeight;
|
||||
} else {
|
||||
pre.style.removeProperty("max-height");
|
||||
}
|
||||
|
||||
const lineCount = buildLineCount(code.textContent || "");
|
||||
const rows = buildLineNumberRows(lineCount);
|
||||
const codes = document.createElement("span");
|
||||
codes.className = "codes";
|
||||
codes.innerHTML = code.innerHTML;
|
||||
|
||||
pre.classList.add("has-line-numbers");
|
||||
code.replaceChildren(rows, codes);
|
||||
bindCodeBlockToggle(pre, code, lineCount, maxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/** 将代码节点上的语言 class 同步到外层 pre。 */
|
||||
function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) {
|
||||
const classList = Array.from(code.classList).filter((item) => item.startsWith("language-"));
|
||||
if (!classList.length) {
|
||||
return;
|
||||
}
|
||||
for (const className of classList) {
|
||||
if (!pre.classList.contains(className)) {
|
||||
pre.classList.add(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除上一次增强生成的行号节点,允许重复增强。 */
|
||||
function resetCodeBlock(code: HTMLElement) {
|
||||
code.parentElement?.classList.remove("has-line-numbers");
|
||||
const codes = code.querySelector(":scope > .codes");
|
||||
if (codes instanceof HTMLElement) {
|
||||
code.innerHTML = codes.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去除 Tiptap 代码块首尾的格式化换行。 */
|
||||
function normalizeCodeContent(code: HTMLElement) {
|
||||
const firstNode = code.firstChild;
|
||||
if (firstNode instanceof Text && firstNode.data.startsWith("\n")) {
|
||||
firstNode.data = firstNode.data.slice(1);
|
||||
if (!firstNode.data) {
|
||||
firstNode.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const lastNode = code.lastChild;
|
||||
if (lastNode instanceof Text && lastNode.data.endsWith("\n")) {
|
||||
lastNode.data = lastNode.data.replace(/\n+$/, "");
|
||||
if (!lastNode.data) {
|
||||
lastNode.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从最近的 data-max-height 容器读取代码块最大高度。 */
|
||||
function resolveCodeBlockMaxHeight(pre: HTMLElement) {
|
||||
const container = pre.closest("[data-max-height]");
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return "";
|
||||
}
|
||||
return container.dataset.maxHeight || "";
|
||||
}
|
||||
|
||||
/** 计算代码块行数,空代码块至少显示一行。 */
|
||||
function buildLineCount(text: string) {
|
||||
if (!text) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(text.split("\n").length, 1);
|
||||
}
|
||||
|
||||
/** 创建与代码行数对应的行号 DOM。 */
|
||||
function buildLineNumberRows(lineCount: number) {
|
||||
const rows = document.createElement("span");
|
||||
rows.className = "line-numbers-rows diselect";
|
||||
for (let i = 0; i < lineCount; i += 1) {
|
||||
const row = document.createElement("span");
|
||||
row.textContent = `${i + 1}`;
|
||||
rows.appendChild(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 为超过阈值的代码块绑定双击展开和恢复行为。 */
|
||||
function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: number, maxHeight: string) {
|
||||
pre.classList.remove("expand");
|
||||
pre.dataset.lineCount = `${lineCount}`;
|
||||
pre.ondblclick = null;
|
||||
|
||||
if (!maxHeight || maxHeight === "auto" || lineCount < 18) {
|
||||
return;
|
||||
}
|
||||
pre.ondblclick = () => {
|
||||
const isExpand = pre.classList.contains("expand");
|
||||
if (isExpand) {
|
||||
pre.style.maxHeight = maxHeight;
|
||||
pre.classList.remove("expand");
|
||||
} else {
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(code).lineHeight || "22") || 22;
|
||||
pre.style.maxHeight = `${Math.ceil(lineCount * lineHeight + 2)}px`;
|
||||
pre.classList.add("expand");
|
||||
}
|
||||
window.getSelection()?.removeAllRanges();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Tiptap 扩展集合。
|
||||
* 图片、视频和 iframe 在这里统一定义尺寸及附件属性,保证编辑和只读视图使用同一套文档模型。
|
||||
*/
|
||||
import { mergeAttributes, Node } from "@tiptap/vue-3";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Subscript from "@tiptap/extension-subscript";
|
||||
import Superscript from "@tiptap/extension-superscript";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import type { Extensions } from "@tiptap/vue-3";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import PrismCodeBlock from "./PrismCodeBlock";
|
||||
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR } from "./shared";
|
||||
|
||||
/** 拼接非空样式声明。 */
|
||||
function buildStyleString(styleList: Array<string | null | undefined>) {
|
||||
return styleList.filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
/** 创建图片、视频和 iframe 共用的尺寸属性。 */
|
||||
function buildNodeSizeAttributes(defaultHeight = "auto") {
|
||||
return {
|
||||
width: {
|
||||
default: "100%",
|
||||
parseHTML: (element: HTMLElement) => element.style.width || "100%"
|
||||
},
|
||||
height: {
|
||||
default: defaultHeight,
|
||||
parseHTML: (element: HTMLElement) => element.style.height || defaultHeight
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 将节点原有样式与尺寸属性合并为 style 字符串。 */
|
||||
function buildNodeSizeStyle(attributes: Record<string, string>) {
|
||||
return buildStyleString([
|
||||
attributes.style,
|
||||
attributes.width ? `width: ${attributes.width}` : null,
|
||||
attributes.height ? `height: ${attributes.height}` : null
|
||||
]);
|
||||
}
|
||||
|
||||
/** 扩展文字样式,补充背景色属性。 */
|
||||
const ExtTextStyle = TextStyle.extend({
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
{
|
||||
types: ["textStyle"],
|
||||
attributes: {
|
||||
backgroundColor: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.style.backgroundColor || null,
|
||||
renderHTML: (attributes: Record<string, string>) => {
|
||||
if (!attributes.backgroundColor) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
style: `background-color: ${attributes.backgroundColor}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
/** 扩展图片节点,支持 base64、尺寸和附件属性。 */
|
||||
const ExtImage = Image.configure({
|
||||
allowBase64: true
|
||||
}).extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...buildNodeSizeAttributes(),
|
||||
attachmentId: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute(ATTACHMENT_ID_ATTR),
|
||||
renderHTML: (attributes: Record<string, string>) => {
|
||||
if (!attributes.attachmentId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
[ATTACHMENT_ID_ATTR]: attributes.attachmentId
|
||||
};
|
||||
}
|
||||
},
|
||||
tempFileId: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute(TEMP_FILE_ID_ATTR),
|
||||
renderHTML: (attributes: Record<string, string>) => {
|
||||
if (!attributes.tempFileId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
[TEMP_FILE_ID_ATTR]: attributes.tempFileId
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
|
||||
return [
|
||||
"img",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: buildNodeSizeStyle(HTMLAttributes)
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
/** 扩展视频节点,作为可拖拽的原子块保存。 */
|
||||
const ExtVideo = Node.create({
|
||||
name: "video",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
...buildNodeSizeAttributes(),
|
||||
attachmentId: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute(ATTACHMENT_ID_ATTR),
|
||||
renderHTML: (attributes: Record<string, string>) => {
|
||||
if (!attributes.attachmentId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
[ATTACHMENT_ID_ATTR]: attributes.attachmentId
|
||||
};
|
||||
}
|
||||
},
|
||||
tempFileId: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute(TEMP_FILE_ID_ATTR),
|
||||
renderHTML: (attributes: Record<string, string>) => {
|
||||
if (!attributes.tempFileId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
[TEMP_FILE_ID_ATTR]: attributes.tempFileId
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "video"
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
|
||||
return [
|
||||
"video",
|
||||
mergeAttributes(
|
||||
{
|
||||
controls: "true",
|
||||
style: buildNodeSizeStyle(HTMLAttributes)
|
||||
},
|
||||
HTMLAttributes
|
||||
)
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
/** 扩展 iframe 节点,限制为块级原子节点并保存尺寸。 */
|
||||
const ExtIframe = Node.create({
|
||||
name: "iframe",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
...buildNodeSizeAttributes("28rem")
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "iframe"
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
|
||||
const style = buildNodeSizeStyle(HTMLAttributes);
|
||||
return [
|
||||
"iframe",
|
||||
mergeAttributes(
|
||||
{
|
||||
class: "tp-iframe",
|
||||
frameborder: "0",
|
||||
allowfullscreen: "true",
|
||||
referrerpolicy: "no-referrer-when-downgrade",
|
||||
style
|
||||
},
|
||||
HTMLAttributes
|
||||
)
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
/** 创建编辑器和只读视图共用的 Tiptap 扩展。 */
|
||||
export function buildTiptapExtensions(placeholder?: string): Extensions {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
underline: false
|
||||
}),
|
||||
PrismCodeBlock.configure({
|
||||
languageClassPrefix: "language-"
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || "请输入内容"
|
||||
}),
|
||||
ExtImage,
|
||||
ExtVideo,
|
||||
ExtIframe,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: "https"
|
||||
}),
|
||||
Underline,
|
||||
ExtTextStyle,
|
||||
Color,
|
||||
Subscript,
|
||||
Superscript,
|
||||
Table.configure({
|
||||
resizable: true
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"]
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Tiptap 编辑器共享类型、常量和数据归一化方法。 */
|
||||
import { h } from "vue";
|
||||
|
||||
/** 上传接口返回的媒体信息。 */
|
||||
export type UploadResult = {
|
||||
id: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/** 编辑器待上传媒体的本地状态。 */
|
||||
export type UploadMediaType = "image" | "video";
|
||||
export type PendingMediaItem = {
|
||||
id: string;
|
||||
type: UploadMediaType;
|
||||
name: string;
|
||||
file: File;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/** 文本对齐方式。 */
|
||||
export type AlignType = "left" | "center" | "right" | "justify";
|
||||
|
||||
/** 富文本媒体节点使用的附件属性名。 */
|
||||
export const ATTACHMENT_ID_ATTR = "data-attachment-id";
|
||||
export const TEMP_FILE_ID_ATTR = "data-temp-file-id";
|
||||
export const TABLE_PICKER_SIZE = 7;
|
||||
export const LOCAL_MEDIA_ID_PREFIX = "local-media:";
|
||||
|
||||
/** 工具栏支持的标题选项。 */
|
||||
export const headingMeta = [
|
||||
{ label: "正文", value: "p", className: "p" },
|
||||
{ label: "标题 1", value: "h1", className: "h1" },
|
||||
{ label: "标题 2", value: "h2", className: "h2" },
|
||||
{ label: "标题 3", value: "h3", className: "h3" },
|
||||
{ label: "标题 4", value: "h4", className: "h4" },
|
||||
{ label: "标题 5", value: "h5", className: "h5" },
|
||||
{ label: "标题 6", value: "h6", className: "h6" }
|
||||
] as const;
|
||||
|
||||
/** 将标题元数据转换为 TDesign 无关的菜单选项结构。 */
|
||||
export function buildHeadingOptions(currentHeadingValue: string) {
|
||||
return headingMeta.map((item) => {
|
||||
const active = currentHeadingValue === item.value;
|
||||
return {
|
||||
active,
|
||||
value: item.value,
|
||||
content: () => h(
|
||||
"span",
|
||||
{ class: ["tp-heading-option", item.className] },
|
||||
item.label
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 清理空段落并统一 HTML 外层空白。 */
|
||||
export function normalizeContent(value?: string) {
|
||||
const text = (value || "").trim();
|
||||
if (!text || text === "<p></p>") {
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, "text/html");
|
||||
const normalized = doc.body.innerHTML.trim();
|
||||
return normalized === "<p></p>" ? "" : normalized;
|
||||
}
|
||||
|
||||
/** 将内边距转换为 0 到 16 rem 范围内的数值。 */
|
||||
export function normalizePaddingValue(value: unknown, fallback: number) {
|
||||
const nextValue = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(nextValue)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(Math.max(nextValue, 0), 16);
|
||||
}
|
||||
|
||||
/** 兼容滑块组件返回单值或数组值。 */
|
||||
export function normalizeSliderValue(value: number | number[], fallback: number) {
|
||||
const nextValue = Array.isArray(value) ? value[0] : value;
|
||||
return normalizePaddingValue(nextValue, fallback);
|
||||
}
|
||||
|
||||
/** 将内边距数值格式化为 CSS rem 值。 */
|
||||
export function formatPaddingValue(value: number) {
|
||||
return `${Number(value.toFixed(2))}rem`;
|
||||
}
|
||||
|
||||
/** 判断媒体 ID 是否为尚未提交到服务器的本地 ID。 */
|
||||
export function isLocalMediaId(id?: string | null) {
|
||||
return !!id && id.startsWith(LOCAL_MEDIA_ID_PREFIX);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/** Tiptap 编辑器状态管理,封装内容同步、内边距同步和生命周期清理。 */
|
||||
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import type { Component, ShallowRef } from "vue";
|
||||
import { resolveCodeEditorKeydown } from "../code";
|
||||
import { buildTiptapExtensions } from "./extensions";
|
||||
import { normalizeContent, normalizePaddingValue } from "./shared";
|
||||
|
||||
/** Tiptap 编辑器实例对外暴露的最小结构,避免平台声明文件依赖 ProseMirror 内部类型。 */
|
||||
type TiptapEditorTextNode = {
|
||||
isText: boolean;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
type TiptapEditorState = {
|
||||
selection: {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
doc: {
|
||||
textBetween: (...args: any[]) => string;
|
||||
descendants: (callback: (node: TiptapEditorTextNode, pos: number) => boolean | void) => void;
|
||||
};
|
||||
tr: {
|
||||
insertText: (...args: any[]) => any;
|
||||
};
|
||||
};
|
||||
|
||||
export type TiptapEditorInstance = {
|
||||
readonly isFocused: boolean;
|
||||
readonly state: TiptapEditorState;
|
||||
readonly view: any;
|
||||
readonly commands: any;
|
||||
destroy: () => void;
|
||||
chain: (...args: any[]) => any;
|
||||
can: (...args: any[]) => any;
|
||||
isActive: (...args: any[]) => boolean;
|
||||
getAttributes: (...args: any[]) => Record<string, unknown>;
|
||||
getHTML: (...args: any[]) => string;
|
||||
};
|
||||
|
||||
type TiptapEditorProps = {
|
||||
modelValue: string;
|
||||
paddingTB?: number;
|
||||
paddingLR?: number;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type TiptapContentEmit = {
|
||||
(event: "update:modelValue", value: string): void;
|
||||
(event: "change", value: string): void;
|
||||
};
|
||||
|
||||
/** 处理代码块中的代码输入行为。 */
|
||||
function handleCodeBlockKeyDown(view: EditorView, event: KeyboardEvent) {
|
||||
const { $from, $to } = view.state.selection;
|
||||
if (!$from.sameParent($to) || $from.parent.type.name !== "codeBlock") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = $from.parent.textContent;
|
||||
const blockStart = $from.start($from.depth);
|
||||
const result = resolveCodeEditorKeydown(code, {
|
||||
start: view.state.selection.from - blockStart,
|
||||
end: view.state.selection.to - blockStart
|
||||
}, event);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const transaction = view.state.tr.insertText(
|
||||
result.value,
|
||||
blockStart,
|
||||
blockStart + code.length
|
||||
);
|
||||
transaction.setSelection(TextSelection.create(
|
||||
transaction.doc,
|
||||
blockStart + result.start,
|
||||
blockStart + result.end
|
||||
));
|
||||
view.dispatch(transaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 处理 Tiptap 编辑器中的 Tab,避免焦点离开并插入制表符。 */
|
||||
function handleTiptapKeyDown(view: EditorView, event: KeyboardEvent) {
|
||||
if (handleCodeBlockKeyDown(view, event)) {
|
||||
return true;
|
||||
}
|
||||
if (event.key !== "Tab" || event.isComposing || event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
if (!view.state.selection.$from.parent.isTextblock) {
|
||||
return true;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.insertText("\t"));
|
||||
return true;
|
||||
}
|
||||
|
||||
type TiptapPaddingUpdate = (
|
||||
event: "update:paddingTB" | "update:paddingLR",
|
||||
value: number
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 创建可编辑的 Tiptap 实例。
|
||||
* 外部内容只在编辑器未聚焦时回灌,代码块输入期间延迟同步,避免光标跳动。
|
||||
*/
|
||||
export function useTiptapEditor(
|
||||
props: TiptapEditorProps,
|
||||
emit: TiptapContentEmit,
|
||||
onPaddingUpdate?: TiptapPaddingUpdate
|
||||
) {
|
||||
const articlePadding = reactive({
|
||||
paddingTB: normalizePaddingValue(props.paddingTB, 1),
|
||||
paddingLR: normalizePaddingValue(props.paddingLR, 1.25)
|
||||
});
|
||||
const lastEmittedContent = ref("");
|
||||
const pendingEmitContent = ref<string | null>(null);
|
||||
|
||||
// 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步。
|
||||
function flushPendingContent() {
|
||||
if (pendingEmitContent.value === null) {
|
||||
return;
|
||||
}
|
||||
lastEmittedContent.value = pendingEmitContent.value;
|
||||
emit("change", pendingEmitContent.value);
|
||||
emit("update:modelValue", pendingEmitContent.value);
|
||||
pendingEmitContent.value = null;
|
||||
}
|
||||
|
||||
function applyExternalContent(nextContent: string) {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
editor.value.commands.setContent(nextContent || "<p></p>", {
|
||||
emitUpdate: false
|
||||
});
|
||||
lastEmittedContent.value = nextContent;
|
||||
}
|
||||
|
||||
const editorBodyStyle = computed(() => {
|
||||
return {
|
||||
"--tp-editor-padding-tb": `${articlePadding.paddingTB}rem`,
|
||||
"--tp-editor-padding-lr": `${articlePadding.paddingLR}rem`
|
||||
};
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: buildTiptapExtensions(props.placeholder),
|
||||
content: "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "editor-body tp-content-body tp-editor-body"
|
||||
},
|
||||
handleKeyDown: handleTiptapKeyDown
|
||||
},
|
||||
onBlur: () => {
|
||||
flushPendingContent();
|
||||
},
|
||||
onSelectionUpdate: ({ editor: instance }) => {
|
||||
if (instance.isActive("codeBlock")) {
|
||||
return;
|
||||
}
|
||||
flushPendingContent();
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
const nextContent = normalizeContent(instance.getHTML());
|
||||
lastEmittedContent.value = nextContent;
|
||||
if (instance.isActive("codeBlock")) {
|
||||
pendingEmitContent.value = nextContent;
|
||||
return;
|
||||
}
|
||||
pendingEmitContent.value = null;
|
||||
emit("change", nextContent);
|
||||
emit("update:modelValue", nextContent);
|
||||
}
|
||||
}) as unknown as ShallowRef<TiptapEditorInstance | undefined>;
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
const nextContent = normalizeContent(value);
|
||||
const currentContent = normalizeContent(editor.value.getHTML());
|
||||
if (nextContent === currentContent) {
|
||||
lastEmittedContent.value = nextContent;
|
||||
return;
|
||||
}
|
||||
if (nextContent === lastEmittedContent.value) {
|
||||
return;
|
||||
}
|
||||
// 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头。
|
||||
if (editor.value.isFocused) {
|
||||
return;
|
||||
}
|
||||
applyExternalContent(nextContent);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.paddingTB, (value) => {
|
||||
articlePadding.paddingTB = normalizePaddingValue(value, articlePadding.paddingTB);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.paddingLR, (value) => {
|
||||
articlePadding.paddingLR = normalizePaddingValue(value, articlePadding.paddingLR);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => articlePadding.paddingTB, (value) => {
|
||||
onPaddingUpdate?.("update:paddingTB", value);
|
||||
});
|
||||
|
||||
watch(() => articlePadding.paddingLR, (value) => {
|
||||
onPaddingUpdate?.("update:paddingLR", value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
EditorContent: EditorContent as unknown as Component,
|
||||
articlePadding,
|
||||
editor,
|
||||
editorBodyStyle
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** 创建只读 Tiptap 视图,并在内容变化后重新增强代码块。 */
|
||||
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
||||
import type { Component, ShallowRef } from "vue";
|
||||
import { buildTiptapExtensions } from "./extensions";
|
||||
import { normalizeContent } from "./shared";
|
||||
import { enhancePrismCodeBlock } from "./PrismCodeBlockView";
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
|
||||
type StringRef = {
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化只读编辑器。
|
||||
* 编辑器负责解析文档,DOM 增强负责行号和折叠,两者分开以便重复同步。
|
||||
*/
|
||||
export function useTiptapView(content: StringRef, maxHeight: StringRef) {
|
||||
const contentRef = ref<HTMLElement>();
|
||||
const editor = useEditor({
|
||||
editable: false,
|
||||
extensions: buildTiptapExtensions(),
|
||||
content: content.value || "<p></p>",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "editor-body tp-content-body"
|
||||
}
|
||||
}
|
||||
}) as unknown as ShallowRef<TiptapEditorInstance | undefined>;
|
||||
|
||||
async function syncCodeBlock() {
|
||||
await nextTick();
|
||||
if (contentRef.value) {
|
||||
enhancePrismCodeBlock(contentRef.value);
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [content.value, maxHeight.value] as const, ([value]) => {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
editor.value.commands.setContent(value || "<p></p>", {
|
||||
emitUpdate: false
|
||||
});
|
||||
void syncCodeBlock();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(editor, () => {
|
||||
void syncCodeBlock();
|
||||
}, { flush: "post" });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
EditorContent: EditorContent as unknown as Component,
|
||||
contentRef,
|
||||
editor,
|
||||
normalizeContent
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user