返回 DeepSeek-Reasonix
markdownSelectionProjection.ts
根目录 / desktop / frontend / src / lib / markdownSelectionProjection.ts
1 import type { Element as HastElement, RootContent as HastRootContent } from "hast";
2 import type { VirtualMarkdownTableData } from "./largeMarkdownTable";
3
4 export interface SelectionProjectionBlock {
5 children: HastRootContent[];
6 virtualTable?: VirtualMarkdownTableData;
7 }
8
9 const BLOCK_TAGS = new Set([
10 "address", "article", "aside", "blockquote", "dd", "details", "div", "dl", "dt",
11 "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
12 "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", "summary", "ul",
13 ]);
14 const IGNORE_TAGS = new Set(["button", "input", "option", "script", "select", "style", "textarea"]);
15
16 function classNames(node: HastElement): string[] {
17 const value: unknown = node.properties.className;
18 if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
19 return typeof value === "string" ? value.split(/\s+/).filter(Boolean) : [];
20 }
21
22 function property(node: HastElement, ...names: string[]): unknown {
23 for (const name of names) {
24 if (Object.prototype.hasOwnProperty.call(node.properties, name)) return node.properties[name];
25 }
26 return undefined;
27 }
28
29 function isHidden(node: HastElement): boolean {
30 const ariaHidden = property(node, "ariaHidden", "aria-hidden");
31 const ignored = property(node, "dataTranscriptSelectionIgnore", "data-transcript-selection-ignore");
32 return ariaHidden === true || ariaHidden === "true" || ignored !== undefined;
33 }
34
35 function latexSource(node: HastElement): string | null {
36 const direct = property(node, "dataLatexSource", "data-latex-source");
37 if (typeof direct === "string" && direct) return direct;
38 const find = (children: readonly HastRootContent[]): string | null => {
39 for (const child of children) {
40 if (child.type !== "element") continue;
41 if (child.tagName === "annotation" && property(child, "encoding") === "application/x-tex") {
42 return child.children.map((part) => part.type === "text" ? part.value : "").join("");
43 }
44 const nested = find(child.children);
45 if (nested) return nested;
46 }
47 return null;
48 };
49 return find(node.children);
50 }
51
52 class TextProjection {
53 private value = "";
54
55 text(text: string): void {
56 this.value += text;
57 }
58
59 break(lines = 1): void {
60 if (!this.value) return;
61 const trailing = /\n*$/.exec(this.value)?.[0].length ?? 0;
62 if (trailing < lines) this.value += "\n".repeat(lines - trailing);
63 }
64
65 result(): string {
66 return this.value.replace(/^\n+|\n+$/g, "");
67 }
68 }
69
70 function nodeText(node: HastRootContent): string {
71 const output = new TextProjection();
72 projectNode(node, output);
73 return output.result();
74 }
75
76 function tableRows(node: HastElement): HastElement[] {
77 const rows: HastElement[] = [];
78 const walk = (element: HastElement) => {
79 if (element.tagName === "tr") {
80 rows.push(element);
81 return;
82 }
83 for (const child of element.children) if (child.type === "element") walk(child);
84 };
85 walk(node);
86 return rows;
87 }
88
89 function projectTable(node: HastElement, output: TextProjection): void {
90 const rows = tableRows(node);
91 rows.forEach((row, rowIndex) => {
92 const cells = row.children.filter(
93 (child): child is HastElement => child.type === "element" && (child.tagName === "td" || child.tagName === "th"),
94 );
95 output.text(cells.map(nodeText).join("\t"));
96 if (rowIndex < rows.length - 1) output.break(1);
97 });
98 }
99
100 function projectNode(node: HastRootContent, output: TextProjection, preserveWhitespace = false): void {
101 if (node.type === "text") {
102 if (!preserveWhitespace && /^\s+$/.test(node.value) && /[\r\n]/.test(node.value)) return;
103 output.text(node.value);
104 return;
105 }
106 if (node.type !== "element") return;
107 if (IGNORE_TAGS.has(node.tagName) || isHidden(node)) return;
108
109 const classes = classNames(node);
110 if (classes.includes("katex-display") || classes.includes("katex")) {
111 const source = latexSource(node);
112 if (source) output.text(classes.includes("katex-display") ? `$$\n${source}\n$$` : `$${source}$`);
113 return;
114 }
115 if (node.tagName === "br") {
116 output.break(1);
117 return;
118 }
119 if (node.tagName === "img") {
120 const alt = property(node, "alt");
121 if (typeof alt === "string") output.text(alt);
122 return;
123 }
124 if (node.tagName === "table") {
125 projectTable(node, output);
126 return;
127 }
128
129 const block = BLOCK_TAGS.has(node.tagName);
130 if (block) output.break(1);
131 const childPreservesWhitespace = preserveWhitespace || node.tagName === "pre" || node.tagName === "code";
132 for (const child of node.children) projectNode(child, output, childPreservesWhitespace);
133 if (block) output.break(1);
134 }
135
136 export function virtualMarkdownTableSelectionText(data: VirtualMarkdownTableData): string {
137 return [data.header, ...data.rows].map((row) => row.join("\t")).join("\n");
138 }
139
140 /** Project rendered, user-readable text from the same HAST used for display. */
141 export function markdownSelectionTextFromBlocks(blocks: readonly SelectionProjectionBlock[]): string {
142 const projected = blocks.map((block) => {
143 if (block.virtualTable) return virtualMarkdownTableSelectionText(block.virtualTable);
144 const output = new TextProjection();
145 for (const child of block.children) projectNode(child, output);
146 return output.result();
147 });
148 // Keep this separator identical to the rendered DOM adapter. Logical
149 // selection endpoints are measured against that DOM projection, so adding
150 // an extra newline between parser blocks would shift every later offset.
151 return projected.filter((text) => text !== "").join("\n");
152 }
153
153 lines TYPESCRIPT