forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | /** |
| 2 | * PreviewPane shows a file from the workspace: rendered Markdown or AsciiDoc | |
| 3 | * (with a Rendered/Source toggle), images at natural size, draw.io diagrams | |
| 4 | * through the diagrams.net viewer (with the XML as Source), and | |
| 5 | * syntax-highlighted read-only code for everything else. | |
| 6 | * | |
| 7 | * @example | |
| 8 | * <PreviewPane path="README.md" /> | |
| 9 | */ | |
| 10 | ||
| 11 | import { useEffect, useState } from "react"; | |
| 12 | import { readFile } from "../api"; | |
| 13 | import { type DocumentKind, kindForPath, languageForPath } from "../lang"; | |
| 14 | import { openEditor } from "../workspace"; | |
| 15 | import { AsciiDocView } from "./AsciiDocView"; | |
| 16 | import { CodeView } from "./CodeView"; | |
| 17 | import { DrawioView } from "./DrawioView"; | |
| 18 | import { ImageView } from "./ImageView"; | |
| 19 | import { Markdown } from "./Markdown"; | |
| 20 | ||
| 21 | type PreviewMode = "rendered" | "source"; | |
| 22 | ||
| 23 | /** Kinds with both a rendered form and a source form. */ | |
| 24 | const toggleableKinds: ReadonlySet<DocumentKind> = new Set([ | |
| 25 | "markdown", | |
| 26 | "asciidoc", | |
| 27 | "drawio", | |
| 28 | ]); | |
| 29 | ||
| 30 | /** | |
| 31 | * usePreviewFile loads the file behind path (images excepted: the browser | |
| 32 | * fetches their bytes itself) and resets the view mode whenever the path | |
| 33 | * changes. `content` stays null while loading. | |
| 34 | */ | |
| 35 | function usePreviewFile(path: string | null) { | |
| 36 | const [content, setContent] = useState<string | null>(null); | |
| 37 | const [error, setError] = useState<string | null>(null); | |
| 38 | const [mode, setMode] = useState<PreviewMode>("rendered"); | |
| 39 | ||
| 40 | useEffect(() => { | |
| 41 | if (!path) { | |
| 42 | return; | |
| 43 | } | |
| 44 | setContent(null); | |
| 45 | setError(null); | |
| 46 | setMode("rendered"); | |
| 47 | if (kindForPath(path) === "image") { | |
| 48 | return; | |
| 49 | } | |
| 50 | readFile(path) | |
| 51 | .then((file) => setContent(file.content)) | |
| 52 | .catch((cause: Error) => setError(cause.message)); | |
| 53 | }, [path]); | |
| 54 | ||
| 55 | return { content, error, mode, setMode }; | |
| 56 | } | |
| 57 | ||
| 58 | export function PreviewPane({ path }: { path: string | null }) { | |
| 59 | const { content, error, mode, setMode } = usePreviewFile(path); | |
| 60 | const kind = path ? kindForPath(path) : "code"; | |
| 61 | ||
| 62 | if (!path) { | |
| 63 | return ( | |
| 64 | <p className="pane-empty">Select a file in the tree to preview it.</p> | |
| 65 | ); | |
| 66 | } | |
| 67 | if (error) { | |
| 68 | return <p className="pane-error">{error}</p>; | |
| 69 | } | |
| 70 | if (kind !== "image" && content === null) { | |
| 71 | return <p className="pane-empty">loading…</p>; | |
| 72 | } | |
| 73 | ||
| 74 | const toggleable = toggleableKinds.has(kind); | |
| 75 | ||
| 76 | return ( | |
| 77 | <div className="preview"> | |
| 78 | <div className="pane-header"> | |
| 79 | <span className="pane-path" title={path}> | |
| 80 | {path.split("/").pop()} | |
| 81 | </span> | |
| 82 | {toggleable && ( | |
| 83 | <div className="pane-modes"> | |
| 84 | <ModeButton current={mode} mode="rendered" onSelect={setMode} /> | |
| 85 | <ModeButton current={mode} mode="source" onSelect={setMode} /> | |
| 86 | </div> | |
| 87 | )} | |
| 88 | {kind !== "image" && ( | |
| 89 | <button | |
| 90 | type="button" | |
| 91 | className="pane-action" | |
| 92 | onClick={() => openEditor(path)} | |
| 93 | > | |
| 94 | Edit | |
| 95 | </button> | |
| 96 | )} | |
| 97 | </div> | |
| 98 | <div className="preview-body"> | |
| 99 | <PreviewContent | |
| 100 | path={path} | |
| 101 | kind={kind} | |
| 102 | content={content ?? ""} | |
| 103 | mode={toggleable ? mode : "rendered"} | |
| 104 | /> | |
| 105 | </div> | |
| 106 | </div> | |
| 107 | ); | |
| 108 | } | |
| 109 | ||
| 110 | function ModeButton({ | |
| 111 | current, | |
| 112 | mode, | |
| 113 | onSelect, | |
| 114 | }: { | |
| 115 | current: PreviewMode; | |
| 116 | mode: PreviewMode; | |
| 117 | onSelect: (mode: PreviewMode) => void; | |
| 118 | }) { | |
| 119 | return ( | |
| 120 | <button | |
| 121 | type="button" | |
| 122 | className={`pane-mode${current === mode ? " pane-mode-active" : ""}`} | |
| 123 | onClick={() => onSelect(mode)} | |
| 124 | > | |
| 125 | {mode === "rendered" ? "Rendered" : "Source"} | |
| 126 | </button> | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | function PreviewContent({ | |
| 131 | path, | |
| 132 | kind, | |
| 133 | content, | |
| 134 | mode, | |
| 135 | }: { | |
| 136 | path: string; | |
| 137 | kind: DocumentKind; | |
| 138 | content: string; | |
| 139 | mode: PreviewMode; | |
| 140 | }) { | |
| 141 | if (kind === "image") { | |
| 142 | return <ImageView path={path} />; | |
| 143 | } | |
| 144 | if (mode === "source" || kind === "code") { | |
| 145 | return <CodeView content={content} language={languageForPath(path)} />; | |
| 146 | } | |
| 147 | if (kind === "drawio") { | |
| 148 | return <DrawioView xml={content} />; | |
| 149 | } | |
| 150 | return ( | |
| 151 | <div className="preview-rendered"> | |
| 152 | {kind === "markdown" ? ( | |
| 153 | <Markdown text={content} /> | |
| 154 | ) : ( | |
| 155 | <AsciiDocView content={content} /> | |
| 156 | )} | |
| 157 | </div> | |
| 158 | ); | |
| 159 | } |