Skip to content

Commit 456bf81

Browse files
committed
feat: 支持更丰富的行内标签(如上下标)
1 parent 4cf29f3 commit 456bf81

10 files changed

Lines changed: 459 additions & 11 deletions

File tree

src/core/decorations/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export const SYNTAX_CLASSES: Record<string, string> = {
102102
heading: "milkup-heading", // 标题
103103
strong_emphasis: "milkup-strong-emphasis", // 粗斜体
104104
escape: "milkup-escape", // 转义
105+
sub: "milkup-sub", // 下标
106+
sup: "milkup-sup", // 上标
107+
html_inline: "milkup-html-inline", // 行内 HTML
105108
};
106109

107110
/** 语法类型关联映射 - 用于处理嵌套语法 */
@@ -116,6 +119,9 @@ const SYNTAX_TYPE_RELATIONS: Record<string, string[]> = {
116119
math_inline: ["math_inline"],
117120
heading: ["heading"],
118121
escape: ["escape"],
122+
sub: ["sub"],
123+
sup: ["sup"],
124+
html_inline: ["html_inline"],
119125
};
120126

121127
/**

src/core/nodeviews/html-block.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,14 +430,18 @@ export class HtmlBlockView implements NodeView {
430430
}
431431

432432
/**
433-
* 检测是否是简单内联 HTML(如 <br />、<hr /> 等自闭合标签)
434-
* 如果是,添加 inline-html 类以隐藏外框
433+
* 根据内容更新 HTML 块的显示样式
434+
* - 有内容时添加 has-content 类,隐藏外框直接显示渲染结果
435+
* - 空内容时保留外框(显示占位提示)
436+
* - 简单自闭合标签添加 inline-html 类
435437
*/
436438
private applyInlineHtmlClass(node: ProseMirrorNode): void {
437439
const content = node.textContent.trim();
438440
// 匹配纯自闭合标签:<tagname /> 或 <tagname/> 或 <tagname attr />
439441
const isSimpleVoid = /^<\w+(?:\s+[^>]*)?\s*\/?>$/.test(content) && !content.includes("\n");
440442
this.dom.classList.toggle("inline-html", isSimpleVoid);
443+
// 有内容时隐藏外框
444+
this.dom.classList.toggle("has-content", content.length > 0);
441445
}
442446

443447
destroy(): void {

src/core/parser/index.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,41 @@ const BLOCK_PATTERNS = {
124124
html_block_start: /^<([a-zA-Z][a-zA-Z0-9]*)/, // 以 < 开头后跟标签名
125125
};
126126

127+
/** 行内元素集合 - 这些标签不应被解析为块级 html_block */
128+
const INLINE_ELEMENTS = new Set([
129+
"a",
130+
"abbr",
131+
"b",
132+
"bdi",
133+
"bdo",
134+
"cite",
135+
"code",
136+
"data",
137+
"dfn",
138+
"em",
139+
"i",
140+
"kbd",
141+
"mark",
142+
"q",
143+
"rp",
144+
"rt",
145+
"ruby",
146+
"s",
147+
"samp",
148+
"small",
149+
"span",
150+
"strong",
151+
"sub",
152+
"sup",
153+
"time",
154+
"u",
155+
"var",
156+
"del",
157+
"ins",
158+
"label",
159+
"font",
160+
]);
161+
127162
/**
128163
* Markdown 解析器类
129164
*/
@@ -293,10 +328,14 @@ export class MarkdownParser {
293328
// HTML 块(排除 autolink 如 <https://...> 和 <http://...>)
294329
const htmlMatch = line.match(BLOCK_PATTERNS.html_block_start);
295330
if (htmlMatch && !/^<(?:https?:\/\/|mailto:)/i.test(line)) {
296-
const result = this.parseHtmlBlock(lines, i);
297-
blocks.push(result.node);
298-
i = result.endIndex + 1;
299-
continue;
331+
const tagName = htmlMatch[1].toLowerCase();
332+
// 行内元素不解析为 html_block,作为段落处理(行内语法由 syntax-detector 检测)
333+
if (!INLINE_ELEMENTS.has(tagName)) {
334+
const result = this.parseHtmlBlock(lines, i);
335+
blocks.push(result.node);
336+
i = result.endIndex + 1;
337+
continue;
338+
}
300339
}
301340

302341
// 段落

src/core/plugins/input-rules.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,78 @@ function linkedImageRule(nodeType: NodeType): InputRule {
512512
});
513513
}
514514

515+
/**
516+
* 创建下标输入规则
517+
* <sub>text</sub>
518+
*/
519+
function subRule(markType: MarkType): InputRule {
520+
return createInlineRuleWithSyntax(/<sub>(.+?)<\/sub>$/, markType, "<sub>", "</sub>", 1, "sub");
521+
}
522+
523+
/**
524+
* 创建上标输入规则
525+
* <sup>text</sup>
526+
*/
527+
function supRule(markType: MarkType): InputRule {
528+
return createInlineRuleWithSyntax(/<sup>(.+?)<\/sup>$/, markType, "<sup>", "</sup>", 1, "sup");
529+
}
530+
531+
/** 不应通过通用 html_inline 规则处理的标签(已有专用 mark) */
532+
const HTML_INLINE_SKIP_TAGS = new Set(["sub", "sup"]);
533+
534+
/**
535+
* 创建通用行内 HTML 输入规则
536+
* <tag attrs>content</tag>
537+
*/
538+
function htmlInlineRule(markType: MarkType): InputRule {
539+
return new InputRule(
540+
/<([a-zA-Z][a-zA-Z0-9]*)(\s(?:[^>"']|"[^"]*"|'[^']*')*)?>(.+?)<\/\1>$/,
541+
(state, match, start, end) => {
542+
const tag = match[1].toLowerCase();
543+
const htmlAttrs = (match[2] || "").trim();
544+
545+
// 跳过有专用 mark 的标签
546+
if (HTML_INLINE_SKIP_TAGS.has(tag)) return null;
547+
548+
const schema = state.schema;
549+
const syntaxMarkerType = schema.marks.syntax_marker;
550+
const contentMark = markType.create({ tag, htmlAttrs });
551+
552+
const prefix = `<${match[1]}${match[2] || ""}>`;
553+
const suffix = `</${match[1]}>`;
554+
const content = match[3];
555+
556+
if (!content) return null;
557+
558+
let tr = state.tr.delete(start, end);
559+
560+
// 插入前缀(带 syntax_marker + html_inline mark)
561+
tr = tr.insertText(prefix, start);
562+
if (syntaxMarkerType) {
563+
const syntaxMark = syntaxMarkerType.create({ syntaxType: "html_inline" });
564+
tr = tr.addMark(start, start + prefix.length, syntaxMark);
565+
}
566+
tr = tr.addMark(start, start + prefix.length, contentMark);
567+
568+
// 插入内容(带 html_inline mark)
569+
const contentStart = start + prefix.length;
570+
tr = tr.insertText(content, contentStart);
571+
tr = tr.addMark(contentStart, contentStart + content.length, contentMark);
572+
573+
// 插入后缀(带 syntax_marker + html_inline mark)
574+
const suffixStart = contentStart + content.length;
575+
tr = tr.insertText(suffix, suffixStart);
576+
if (syntaxMarkerType) {
577+
const syntaxMark = syntaxMarkerType.create({ syntaxType: "html_inline" });
578+
tr = tr.addMark(suffixStart, suffixStart + suffix.length, syntaxMark);
579+
}
580+
tr = tr.addMark(suffixStart, suffixStart + suffix.length, contentMark);
581+
582+
return tr;
583+
}
584+
);
585+
}
586+
515587
/**
516588
* 创建数学块输入规则
517589
* $$ 在行首输入时创建数学块
@@ -649,6 +721,15 @@ export function createInputRulesPlugin(schema: Schema = milkupSchema): Plugin {
649721
if (schema.marks.math_inline) {
650722
rules.push(mathInlineRule(schema.marks.math_inline));
651723
}
724+
if (schema.marks.sub) {
725+
rules.push(subRule(schema.marks.sub));
726+
}
727+
if (schema.marks.sup) {
728+
rules.push(supRule(schema.marks.sup));
729+
}
730+
if (schema.marks.html_inline) {
731+
rules.push(htmlInlineRule(schema.marks.html_inline));
732+
}
652733

653734
return inputRules({ rules });
654735
}

src/core/plugins/paste.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ export function createPastePlugin(config: PastePluginConfig = {}): Plugin {
8686

8787
// 检查是否包含 Markdown 语法
8888
if (!containsMarkdownSyntax(text)) {
89+
// 检查是否有外部 HTML(非编辑器内部复制)
90+
const html = clipboardData.getData("text/html");
91+
if (html && !html.includes("data-pm-slice")) {
92+
// 外部 HTML 粘贴,作为纯文本插入,避免 ProseMirror 解析 HTML marks
93+
const tr = view.state.tr.insertText(text);
94+
view.dispatch(tr);
95+
return true;
96+
}
8997
return false; // 让默认处理器处理
9098
}
9199

@@ -314,6 +322,8 @@ function containsMarkdownSyntax(text: string): boolean {
314322
/==[^=]+==/, // 高亮
315323
/^\s*\$\$/m, // 数学块(支持缩进)
316324
/\$[^$]+\$/, // 行内数学
325+
/<su[bp]>.+?<\/su[bp]>/, // sub/sup
326+
/<[a-zA-Z][a-zA-Z0-9]*(?:\s[^>]*)?>.*?<\/[a-zA-Z][a-zA-Z0-9]*>/, // 行内 HTML
317327
/^- \[[ xX]\]/m, // 任务列表
318328
/^\|.+\|$/m, // 表格
319329
];

src/core/plugins/source-view-transform.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,44 @@ function transformParagraphsToHtmlBlock(
325325
const content = lines.join("\n");
326326

327327
// 验证内容是否以 HTML 标签开头
328-
if (!content.match(/^<[a-zA-Z]/)) return null;
328+
const tagMatch = content.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
329+
if (!tagMatch) return null;
330+
331+
// 行内标签不应恢复为 html_block
332+
const inlineElements = new Set([
333+
"a",
334+
"abbr",
335+
"b",
336+
"bdi",
337+
"bdo",
338+
"cite",
339+
"code",
340+
"data",
341+
"dfn",
342+
"em",
343+
"i",
344+
"kbd",
345+
"mark",
346+
"q",
347+
"rp",
348+
"rt",
349+
"ruby",
350+
"s",
351+
"samp",
352+
"small",
353+
"span",
354+
"strong",
355+
"sub",
356+
"sup",
357+
"time",
358+
"u",
359+
"var",
360+
"del",
361+
"ins",
362+
"label",
363+
"font",
364+
]);
365+
if (inlineElements.has(tagMatch[1].toLowerCase())) return null;
329366

330367
return schema.nodes.html_block.create({}, content ? schema.text(content) : null);
331368
}

0 commit comments

Comments
 (0)