mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +02:00
Initial commit
This commit is contained in:
commit
8aa223bc7b
40 files changed
+4320
No files matched your search
@@ -0,0 +1,284 @@
|
||||
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, 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(ShortLabel, Title) {
|
||||
super();
|
||||
this.ShortLabel = ShortLabel;
|
||||
this.Title = Title;
|
||||
}
|
||||
|
||||
eq(Other) {
|
||||
return this.ShortLabel === Other.ShortLabel && this.Title === Other.Title;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const Element = document.createElement("span");
|
||||
Element.className = "BlameGutterMarker";
|
||||
Element.textContent = this.ShortLabel;
|
||||
Element.title = this.Title;
|
||||
return Element;
|
||||
}
|
||||
}
|
||||
|
||||
function BuildBlameGutter(BlameMap) {
|
||||
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;
|
||||
|
||||
const ShortLabel = Entry.Username.slice(0, 6);
|
||||
const Title = `${Entry.Username} — ${new Date(Entry.Timestamp).toLocaleString()}`;
|
||||
return new BlameMarker(ShortLabel, Title);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function PreviewPane({
|
||||
RootName,
|
||||
FilePath,
|
||||
FileType,
|
||||
OnNavigateLink,
|
||||
ShowGitBlame,
|
||||
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>
|
||||
{/* Raw, path-shaped URL (not the query-param /api/file form) so
|
||||
relative <link>/<script>/<img> references inside the HTML
|
||||
resolve against their real sibling files. */}
|
||||
<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)] : []),
|
||||
]}
|
||||
onChange={SetContent}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="PreviewEmpty">No preview available for this file type.</div>;
|
||||
}
|
||||
Reference in new issue
Block a user