refactor editor

This commit is contained in:
Timi
2026-08-25 17:35:19 +08:00
parent 0c5a4d4fd9
commit 6fa75b3045
44 changed files with 334 additions and 3608 deletions
+150
View File
@@ -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;
}
+229
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
+15
View File
@@ -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";
+10
View File
@@ -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;
}
+142
View File
@@ -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: ""
};
};
+186
View File
@@ -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
})
];
}
});
+130
View File
@@ -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();
};
}
+272
View File
@@ -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"]
})
];
}
+92
View File
@@ -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);
}
+230
View File
@@ -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
};
}
+61
View File
@@ -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
};
}