mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Feature/pdf to markdown agent (#6271)
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
5b9ef852ab
commit
ece1bb6865
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: copied
|
||||
? "var(--mantine-color-green-0)"
|
||||
: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.7em",
|
||||
padding: "2px 8px",
|
||||
color: copied
|
||||
? "var(--mantine-color-green-7)"
|
||||
: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{copied ? "✓ Copied" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const components: Components = {
|
||||
pre: ({ children }) => {
|
||||
const codeText = React.isValidElement(children)
|
||||
? String((children.props as { children?: unknown }).children ?? "")
|
||||
: String(children ?? "");
|
||||
return (
|
||||
<div style={{ position: "relative", margin: "8px 0" }}>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
padding: "10px 52px 10px 14px",
|
||||
borderRadius: 4,
|
||||
overflowX: "auto",
|
||||
fontSize: "0.85em",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
<CopyButton text={codeText} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div style={{ overflowX: "auto", margin: "10px 0" }}>
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, style }) => (
|
||||
<th
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
padding: "6px 10px",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, style }) => (
|
||||
<td
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
padding: "5px 10px",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
export function renderMarkdown(content: string): React.ReactNode[] {
|
||||
return [
|
||||
<ReactMarkdown key="md" remarkPlugins={[remarkGfm]} components={components}>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
@@ -12,153 +12,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatFileSize } from "@app/utils/fileUtils";
|
||||
|
||||
// ─── Markdown renderer ────────────────────────────────────────────────────────
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Patterns: **bold**, *italic*, `code`, [link](url)
|
||||
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||
let last = 0;
|
||||
let match;
|
||||
let key = 0;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > last) parts.push(text.slice(last, match.index));
|
||||
if (match[2]) parts.push(<strong key={key++}>{match[2]}</strong>);
|
||||
else if (match[3]) parts.push(<em key={key++}>{match[3]}</em>);
|
||||
else if (match[4])
|
||||
parts.push(
|
||||
<code
|
||||
key={key++}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
padding: "0 3px",
|
||||
borderRadius: 3,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
>
|
||||
{match[4]}
|
||||
</code>,
|
||||
);
|
||||
else if (match[5])
|
||||
parts.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={match[6]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "var(--mantine-color-blue-6)" }}
|
||||
>
|
||||
{match[5]}
|
||||
</a>,
|
||||
);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < text.length) parts.push(text.slice(last));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): React.ReactNode[] {
|
||||
const lines = text.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeLines: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith("```")) {
|
||||
if (inCodeBlock) {
|
||||
elements.push(
|
||||
<pre
|
||||
key={i}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 4,
|
||||
overflowX: "auto",
|
||||
fontSize: "0.85em",
|
||||
margin: "4px 0",
|
||||
}}
|
||||
>
|
||||
<code>{codeLines.join("\n")}</code>
|
||||
</pre>,
|
||||
);
|
||||
codeLines = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeLines.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("### ")) {
|
||||
elements.push(
|
||||
<Text key={i} fw={600} size="md" mt="xs" mb={2}>
|
||||
{renderInline(line.slice(4))}
|
||||
</Text>,
|
||||
);
|
||||
} else if (line.startsWith("## ")) {
|
||||
elements.push(
|
||||
<Text key={i} fw={700} size="lg" mt="sm" mb={4}>
|
||||
{renderInline(line.slice(3))}
|
||||
</Text>,
|
||||
);
|
||||
} else if (line.startsWith("# ")) {
|
||||
elements.push(
|
||||
<Text key={i} fw={800} size="xl" mt="md" mb={6}>
|
||||
{renderInline(line.slice(2))}
|
||||
</Text>,
|
||||
);
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
elements.push(
|
||||
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
|
||||
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>
|
||||
{"\u2022"}
|
||||
</Text>
|
||||
<Text size="sm" style={{ lineHeight: 1.6 }}>
|
||||
{renderInline(line.slice(2))}
|
||||
</Text>
|
||||
</Group>,
|
||||
);
|
||||
} else if (/^\d+\.\s/.test(line)) {
|
||||
const num = line.match(/^(\d+)\.\s/)?.[1];
|
||||
const rest = line.replace(/^\d+\.\s/, "");
|
||||
elements.push(
|
||||
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
|
||||
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>
|
||||
{num}.
|
||||
</Text>
|
||||
<Text size="sm" style={{ lineHeight: 1.6 }}>
|
||||
{renderInline(rest)}
|
||||
</Text>
|
||||
</Group>,
|
||||
);
|
||||
} else if (line.trim() === "" || line === "---" || line === "***") {
|
||||
elements.push(<Box key={i} style={{ height: 8 }} />);
|
||||
} else {
|
||||
elements.push(
|
||||
<Text key={i} size="sm" style={{ lineHeight: 1.7 }}>
|
||||
{renderInline(line)}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
// ─── Text / Markdown viewer ───────────────────────────────────────────────────
|
||||
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
|
||||
|
||||
interface TextViewerProps {
|
||||
file: File;
|
||||
@@ -176,6 +30,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
}, [file]);
|
||||
|
||||
const lines = content?.split("\n") ?? [];
|
||||
const renderedMarkdown = useMemo(
|
||||
() =>
|
||||
content !== null && isMarkdown && renderMd
|
||||
? renderMarkdown(content)
|
||||
: null,
|
||||
[content, isMarkdown, renderMd],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ height: "100%", flex: 1 }}>
|
||||
@@ -222,9 +83,17 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
{t("viewer.nonPdf.loading")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : isMarkdown && renderMd ? (
|
||||
<Box style={{ maxWidth: 800, margin: "0 auto", padding: "8px 0" }}>
|
||||
{renderMarkdown(content)}
|
||||
) : renderedMarkdown !== null ? (
|
||||
<Box
|
||||
style={{
|
||||
maxWidth: 800,
|
||||
margin: "0 auto",
|
||||
padding: "20px 28px",
|
||||
background: "#ffffff",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{renderedMarkdown}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
@@ -240,8 +109,8 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<Box key={i} component="div" style={{ display: "table-row" }}>
|
||||
{lines.map((line, idx) => (
|
||||
<Box key={idx} component="div" style={{ display: "table-row" }}>
|
||||
{showLineNumbers && (
|
||||
<Box
|
||||
component="span"
|
||||
@@ -256,7 +125,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
minWidth: `${String(lines.length).length + 1}ch`,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
{idx + 1}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
@@ -266,7 +135,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
paddingLeft: showLineNumbers ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
{line || "\u00A0"}
|
||||
{line || " "}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user