mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
311 lines
8.6 KiB
React
311 lines
8.6 KiB
React
import React, { useEffect, useState } from "react";
|
|
import CodeMirror from "@uiw/react-codemirror";
|
|
import { gutter, GutterMarker } from "@codemirror/view";
|
|
import { markdown } from "@codemirror/lang-markdown";
|
|
import { html } from "@codemirror/lang-html";
|
|
import { json } from "@codemirror/lang-json";
|
|
import MarkdownIt from "markdown-it";
|
|
import MarkdownItTaskLists from "markdown-it-task-lists";
|
|
|
|
import { FetchBlame, FetchFile, GetAvatarUrl, GetFileUrl, GetRawFileUrl, SaveFile } from "../Api.js";
|
|
|
|
const MarkdownRenderer = new MarkdownIt({ breaks: true }).use(MarkdownItTaskLists, {
|
|
enabled: true,
|
|
label: true,
|
|
});
|
|
|
|
const DefaultImageRenderer =
|
|
MarkdownRenderer.renderer.rules.image ||
|
|
((Tokens, Index, Options, Env, Self) => Self.renderToken(Tokens, Index, Options));
|
|
|
|
MarkdownRenderer.renderer.rules.image = (Tokens, Index, Options, Env, Self) => {
|
|
const Token = Tokens[Index];
|
|
const SrcAttrIndex = Token.attrIndex("src");
|
|
|
|
if (SrcAttrIndex >= 0 && Env && Env.ResolveImageSrc) {
|
|
Token.attrs[SrcAttrIndex][1] = Env.ResolveImageSrc(Token.attrs[SrcAttrIndex][1]);
|
|
}
|
|
|
|
return DefaultImageRenderer(Tokens, Index, Options, Env, Self);
|
|
};
|
|
|
|
const TextTypes = ["markdown", "text", "config", "html"];
|
|
|
|
const LinkedFileTypeMap = {
|
|
".md": "markdown",
|
|
".markdown": "markdown",
|
|
".txt": "text",
|
|
".toml": "config",
|
|
".yaml": "config",
|
|
".yml": "config",
|
|
".json": "config",
|
|
".html": "html",
|
|
".htm": "html",
|
|
".png": "image",
|
|
".jpg": "image",
|
|
".jpeg": "image",
|
|
".gif": "image",
|
|
".webp": "image",
|
|
".svg": "image",
|
|
".mp4": "video",
|
|
".webm": "video",
|
|
".mov": "video",
|
|
".mkv": "video",
|
|
};
|
|
|
|
function ClassifyLinkedFile(LinkedPath) {
|
|
const DotIndex = LinkedPath.lastIndexOf(".");
|
|
const Extension = DotIndex === -1 ? "" : LinkedPath.slice(DotIndex).toLowerCase();
|
|
return LinkedFileTypeMap[Extension] || "unknown";
|
|
}
|
|
|
|
function ResolveRelativeLink(CurrentFilePath, Href) {
|
|
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(Href) || Href.startsWith("#") || Href.startsWith("mailto:")) {
|
|
return null;
|
|
}
|
|
|
|
const CurrentDirectory = CurrentFilePath.includes("/")
|
|
? CurrentFilePath.slice(0, CurrentFilePath.lastIndexOf("/"))
|
|
: "";
|
|
const Combined = CurrentDirectory ? `${CurrentDirectory}/${Href}` : Href;
|
|
const ResolvedParts = [];
|
|
|
|
for (const Part of Combined.split("/")) {
|
|
if (Part === "" || Part === ".") continue;
|
|
if (Part === "..") {
|
|
ResolvedParts.pop();
|
|
} else {
|
|
ResolvedParts.push(Part);
|
|
}
|
|
}
|
|
|
|
return ResolvedParts.join("/");
|
|
}
|
|
|
|
function GetLanguageExtension(FileType) {
|
|
if (FileType === "markdown") return [markdown()];
|
|
if (FileType === "html") return [html()];
|
|
if (FileType === "config") return [json()];
|
|
return [];
|
|
}
|
|
|
|
class BlameMarker extends GutterMarker {
|
|
constructor(Mode, Content, Colour, Title) {
|
|
super();
|
|
this.Mode = Mode;
|
|
this.Content = Content;
|
|
this.Colour = Colour;
|
|
this.Title = Title;
|
|
}
|
|
|
|
eq(Other) {
|
|
return (
|
|
this.Mode === Other.Mode &&
|
|
this.Content === Other.Content &&
|
|
this.Colour === Other.Colour &&
|
|
this.Title === Other.Title
|
|
);
|
|
}
|
|
|
|
toDOM() {
|
|
if (this.Mode === "avatar") {
|
|
const Image = document.createElement("img");
|
|
Image.className = "BlameGutterAvatar";
|
|
Image.src = this.Content;
|
|
Image.title = this.Title;
|
|
Image.alt = "";
|
|
return Image;
|
|
}
|
|
|
|
const Element = document.createElement("span");
|
|
Element.className = `BlameGutterMarker${this.Mode === "hidden" ? " BlameGutterHidden" : ""}`;
|
|
Element.textContent = this.Content;
|
|
Element.title = this.Title;
|
|
if (this.Colour) {
|
|
Element.style.color = `var(--${this.Colour})`;
|
|
}
|
|
return Element;
|
|
}
|
|
}
|
|
|
|
function BuildBlameGutter(BlameMap, ShowAvatars) {
|
|
return gutter({
|
|
class: "BlameGutter",
|
|
lineMarker(view, line) {
|
|
const LineNumber = view.state.doc.lineAt(line.from).number;
|
|
const Entry = BlameMap[String(LineNumber)];
|
|
if (!Entry) return null;
|
|
|
|
if (Entry.Hidden) {
|
|
const Title = Entry.Timestamp ? new Date(Entry.Timestamp).toLocaleString() : "";
|
|
return new BlameMarker("hidden", "•••", null, Title);
|
|
}
|
|
|
|
const Title = `${Entry.Username} - ${new Date(Entry.Timestamp).toLocaleString()}`;
|
|
|
|
if (ShowAvatars && Entry.HasAvatar) {
|
|
return new BlameMarker("avatar", GetAvatarUrl(Entry.Username), null, Title);
|
|
}
|
|
|
|
return new BlameMarker("text", Entry.Username.slice(0, 6), Entry.Colour, Title);
|
|
},
|
|
});
|
|
}
|
|
|
|
export default function PreviewPane({
|
|
RootName,
|
|
FilePath,
|
|
FileType,
|
|
OnNavigateLink,
|
|
ShowGitBlame,
|
|
ShowBlameAvatars,
|
|
StickyToolbar,
|
|
}) {
|
|
const [Content, SetContent] = useState("");
|
|
const [IsEditing, SetIsEditing] = useState(false);
|
|
const [IsSaving, SetIsSaving] = useState(false);
|
|
const [SaveError, SetSaveError] = useState(null);
|
|
const [BlameMap, SetBlameMap] = useState({});
|
|
|
|
useEffect(() => {
|
|
SetIsEditing(false);
|
|
SetSaveError(null);
|
|
SetBlameMap({});
|
|
|
|
if (FilePath && TextTypes.includes(FileType)) {
|
|
FetchFile(RootName, FilePath).then(SetContent);
|
|
|
|
if (ShowGitBlame) {
|
|
FetchBlame(RootName, FilePath)
|
|
.then(SetBlameMap)
|
|
.catch(() => SetBlameMap({}));
|
|
}
|
|
}
|
|
}, [RootName, FilePath, FileType, ShowGitBlame]);
|
|
|
|
if (!FilePath) {
|
|
return <div className="PreviewEmpty">Select a file to preview.</div>;
|
|
}
|
|
|
|
const ToolbarClass = `PreviewToolbar${StickyToolbar ? " PreviewToolbarSticky" : ""}`;
|
|
|
|
async function HandleSave() {
|
|
SetIsSaving(true);
|
|
SetSaveError(null);
|
|
try {
|
|
await SaveFile(RootName, FilePath, Content);
|
|
if (ShowGitBlame) {
|
|
FetchBlame(RootName, FilePath)
|
|
.then(SetBlameMap)
|
|
.catch(() => {});
|
|
}
|
|
} catch (Error) {
|
|
SetSaveError(Error.message);
|
|
} finally {
|
|
SetIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function HandleMarkdownClick(Event) {
|
|
const AnchorElement = Event.target.closest("a");
|
|
if (!AnchorElement) return;
|
|
|
|
const Href = AnchorElement.getAttribute("href");
|
|
if (!Href) return;
|
|
|
|
const ResolvedPath = ResolveRelativeLink(FilePath, Href);
|
|
if (ResolvedPath === null) return;
|
|
|
|
Event.preventDefault();
|
|
OnNavigateLink(ResolvedPath, ClassifyLinkedFile(ResolvedPath));
|
|
}
|
|
|
|
if (FileType === "image") {
|
|
return (
|
|
<div className="PreviewMedia">
|
|
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (FileType === "video") {
|
|
return (
|
|
<div className="PreviewMedia">
|
|
<video controls src={GetFileUrl(RootName, FilePath)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (FileType === "html" && !IsEditing) {
|
|
return (
|
|
<div className="PreviewFrame">
|
|
<div className={ToolbarClass}>
|
|
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
|
</div>
|
|
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (FileType === "markdown" && !IsEditing) {
|
|
return (
|
|
<div className="PreviewMarkdown">
|
|
<div className={ToolbarClass}>
|
|
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
|
</div>
|
|
<div
|
|
className="MarkdownBody"
|
|
onClick={HandleMarkdownClick}
|
|
dangerouslySetInnerHTML={{
|
|
__html: MarkdownRenderer.render(Content, {
|
|
ResolveImageSrc: (Src) => {
|
|
const ResolvedPath = ResolveRelativeLink(FilePath, Src);
|
|
return ResolvedPath === null ? Src : GetFileUrl(RootName, ResolvedPath);
|
|
},
|
|
}),
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (FileType === "text" && !IsEditing) {
|
|
return (
|
|
<div className="PreviewText">
|
|
<div className={ToolbarClass}>
|
|
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
|
</div>
|
|
<pre className="TextBody">{Content}</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (TextTypes.includes(FileType)) {
|
|
return (
|
|
<div className="PreviewEditor">
|
|
<div className={ToolbarClass}>
|
|
{(FileType === "markdown" || FileType === "text" || FileType === "html") && (
|
|
<button onClick={() => SetIsEditing(false)}>View</button>
|
|
)}
|
|
<button onClick={HandleSave} disabled={IsSaving}>
|
|
{IsSaving ? "Saving…" : "Save"}
|
|
</button>
|
|
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
|
|
</div>
|
|
<CodeMirror
|
|
value={Content}
|
|
height="100%"
|
|
extensions={[
|
|
...GetLanguageExtension(FileType),
|
|
...(ShowGitBlame ? [BuildBlameGutter(BlameMap, ShowBlameAvatars)] : []),
|
|
]}
|
|
onChange={SetContent}
|
|
theme="dark"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <div className="PreviewEmpty">No preview available for this file type.</div>;
|
|
}
|