Files
SaraView/Frontend/src/Components/PreviewPane.jsx
T

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>;
}