| 1 | import { createMarkdownEditor, livePreview, type MarkdownEditor } from "@clo/markdown-editor"; |
| 2 | import { syntaxTree } from "@codemirror/language"; |
| 3 | import { Compartment, EditorState, Prec, Transaction } from "@codemirror/state"; |
| 4 | import { EditorView } from "@codemirror/view"; |
| 5 | import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts"; |
| 6 | import { |
| 7 | classifyMarkdownImageSource, |
| 8 | markdownImageSourceFragment, |
| 9 | } from "@t3tools/client-runtime/markdown-images"; |
| 10 | import { workspaceRelativeFilePath } from "@t3tools/shared/path"; |
| 11 | import { resolveMarkdownFileLinkTarget } from "@t3tools/shared/markdownLinks"; |
| 12 | import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks"; |
| 13 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 14 | |
| 15 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 16 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 17 | import { toastManager } from "~/components/ui/toast"; |
| 18 | import { useAssetUrls } from "~/assets/assetUrls"; |
| 19 | import { composerCodeBlockHighlight } from "~/components/composerCodeBlockHighlight"; |
| 20 | import { workspaceAssetResource } from "./filePreviewMode"; |
| 21 | import { useMarkdownVideos } from "./FileMarkdownVideos"; |
| 22 | |
| 23 | export function FileMarkdownPreview(props: { |
| 24 | readonly cwd: string; |
| 25 | readonly relativePath: string; |
| 26 | readonly text: string; |
| 27 | readonly threadRef: ScopedThreadRef; |
| 28 | readonly draft: boolean; |
| 29 | readonly readOnly: boolean; |
| 30 | readonly revision: string | null; |
| 31 | readonly onChange: (text: string) => void; |
| 32 | }) { |
| 33 | const openLink = useOpenLink(props.threadRef); |
| 34 | const host = useRef<HTMLDivElement>(null); |
| 35 | const editor = useRef<MarkdownEditor>(null); |
| 36 | const latest = useRef(props); |
| 37 | const [sources, setSources] = useState<string[]>([]); |
| 38 | const [presentation] = useState(() => new Compartment()); |
| 39 | const [editing] = useState(() => new Compartment()); |
| 40 | const lastSeparator = Math.max( |
| 41 | props.relativePath.lastIndexOf("/"), |
| 42 | props.relativePath.lastIndexOf("\\"), |
| 43 | ); |
| 44 | const imageBaseDir = |
| 45 | lastSeparator >= 0 |
| 46 | ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd) |
| 47 | : props.cwd; |
| 48 | const { renderVideo, portals } = useMarkdownVideos({ |
| 49 | cwd: props.cwd, |
| 50 | mediaBaseDir: imageBaseDir, |
| 51 | threadRef: props.threadRef, |
| 52 | draft: props.draft, |
| 53 | revision: props.revision, |
| 54 | }); |
| 55 | const resources = useMemo( |
| 56 | () => |
| 57 | sources.map((source): AssetResource => |
| 58 | workspaceAssetResource({ |
| 59 | kind: "media-file", |
| 60 | threadRef: props.threadRef, |
| 61 | draft: props.draft, |
| 62 | workspaceRoot: props.cwd, |
| 63 | absolutePath: source, |
| 64 | }), |
| 65 | ), |
| 66 | [props.cwd, props.draft, props.threadRef, sources], |
| 67 | ); |
| 68 | const urls = useAssetUrls(props.threadRef.environmentId, resources); |
| 69 | const resolveUrl = useCallback( |
| 70 | (url: string) => { |
| 71 | const source = classifyMarkdownImageSource(url, imageBaseDir); |
| 72 | if (source._tag === "Direct") return source.uri; |
| 73 | if (source._tag === "Blocked") return "about:blank"; |
| 74 | const index = sources.indexOf(source.path); |
| 75 | if (index === -1) { |
| 76 | queueMicrotask(() => |
| 77 | setSources((current) => |
| 78 | current.includes(source.path) ? current : [...current, source.path], |
| 79 | ), |
| 80 | ); |
| 81 | return "about:blank"; |
| 82 | } |
| 83 | const resolved = urls[index]; |
| 84 | if (!resolved) return "about:blank"; |
| 85 | const revision = |
| 86 | props.revision === null |
| 87 | ? "" |
| 88 | : `${resolved.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.revision)}`; |
| 89 | return `${resolved}${revision}${markdownImageSourceFragment(url)}`; |
| 90 | }, |
| 91 | [imageBaseDir, props.revision, sources, urls], |
| 92 | ); |
| 93 | |
| 94 | const activateLink = useCallback( |
| 95 | (view: EditorView, position: number) => { |
| 96 | let node = syntaxTree(view.state).resolveInner(position, 1); |
| 97 | while (node.name !== "Link" && node.parent) node = node.parent; |
| 98 | const destination = node.name === "Link" ? node.getChild("URL") : null; |
| 99 | if (!destination) return false; |
| 100 | const href = view.state.sliceDoc(destination.from, destination.to).replace(/^<|>$/g, ""); |
| 101 | const filePath = resolveMarkdownFileLinkTarget(href, props.cwd, imageBaseDir); |
| 102 | if (filePath !== null) { |
| 103 | const target = splitFilePathPosition(filePath); |
| 104 | useRightPanelStore |
| 105 | .getState() |
| 106 | .openFile( |
| 107 | props.threadRef, |
| 108 | workspaceRelativeFilePath(target.path, props.cwd) ?? target.path, |
| 109 | target.line, |
| 110 | ); |
| 111 | return true; |
| 112 | } |
| 113 | if (!/^(?:https?:|mailto:)/i.test(href)) return false; |
| 114 | void openLink(href).catch((cause: unknown) => |
| 115 | toastManager.add({ |
| 116 | type: "error", |
| 117 | title: "Unable to open link", |
| 118 | description: cause instanceof Error ? cause.message : "Try opening the link again.", |
| 119 | }), |
| 120 | ); |
| 121 | return true; |
| 122 | }, |
| 123 | [imageBaseDir, openLink, props.cwd, props.threadRef], |
| 124 | ); |
| 125 | |
| 126 | useLayoutEffect(() => { |
| 127 | latest.current = props; |
| 128 | }); |
| 129 | // oxlint-disable react/exhaustive-effect-dependencies -- One editor per file; controlled updates below preserve the mounted view. |
| 130 | useLayoutEffect(() => { |
| 131 | const instance = createMarkdownEditor({ |
| 132 | preset: "document", |
| 133 | mentions: false, |
| 134 | preview: false, |
| 135 | doc: latest.current.text, |
| 136 | onChange: (text) => { |
| 137 | if (text !== latest.current.text && !latest.current.readOnly) latest.current.onChange(text); |
| 138 | }, |
| 139 | extensions: [ |
| 140 | presentation.of([]), |
| 141 | editing.of([]), |
| 142 | composerCodeBlockHighlight, |
| 143 | Prec.highest( |
| 144 | EditorView.theme({ |
| 145 | "&": { |
| 146 | height: "100%", |
| 147 | fontSize: "inherit", |
| 148 | color: "var(--foreground)", |
| 149 | "--markdown-text": "var(--foreground)", |
| 150 | "--markdown-muted": "var(--muted-foreground)", |
| 151 | "--markdown-panel": "var(--muted)", |
| 152 | "--markdown-accent": "var(--primary)", |
| 153 | }, |
| 154 | ".cm-scroller": { |
| 155 | fontFamily: "var(--font-sans)", |
| 156 | scrollbarGutter: "stable both-edges", |
| 157 | }, |
| 158 | ".cm-content": { |
| 159 | maxWidth: "56rem", |
| 160 | margin: "0 auto", |
| 161 | padding: "20px 24px", |
| 162 | flex: "1", |
| 163 | minWidth: "0", |
| 164 | }, |
| 165 | ".cm-lp-strong": { |
| 166 | color: |
| 167 | "light-dark(oklch(from var(--message-action) min(l, 0.55) c h), oklch(from var(--message-action) max(l, 0.65) c h))", |
| 168 | }, |
| 169 | ".cm-lp-em": { color: "var(--info-foreground)" }, |
| 170 | ".cm-lp-strong.cm-lp-em, .cm-lp-strong .cm-lp-em, .cm-lp-em .cm-lp-strong": { |
| 171 | color: "var(--error-foreground)", |
| 172 | backgroundColor: "color-mix(in srgb, var(--error) 10%, transparent)", |
| 173 | }, |
| 174 | ".cm-lp-code, .cm-lp-codeblock": { |
| 175 | fontFamily: |
| 176 | 'var(--chat-code-font, "Recursive"), "T3 Markdown Symbols", var(--font-mono)', |
| 177 | fontVariationSettings: '"CASL" 0.15, "MONO" 1', |
| 178 | fontSize: "var(--font-size-code, 13px)", |
| 179 | }, |
| 180 | ".cm-lp-codeblock": { lineHeight: "1.25" }, |
| 181 | "&.cm-focused": { outline: "none" }, |
| 182 | ".cm-content:focus-visible": { |
| 183 | outline: "2px solid var(--ring)", |
| 184 | outlineOffset: "-2px", |
| 185 | }, |
| 186 | }), |
| 187 | ), |
| 188 | ], |
| 189 | }); |
| 190 | editor.current = instance; |
| 191 | host.current?.append(instance.dom); |
| 192 | return () => { |
| 193 | editor.current = null; |
| 194 | instance.destroy(); |
| 195 | }; |
| 196 | }, []); |
| 197 | // oxlint-enable react/exhaustive-effect-dependencies |
| 198 | |
| 199 | useEffect(() => { |
| 200 | editor.current?.view.dispatch({ |
| 201 | effects: [ |
| 202 | presentation.reconfigure( |
| 203 | livePreview({ blockWidgets: true, html: true, resolveUrl, renderVideo }), |
| 204 | ), |
| 205 | editing.reconfigure([ |
| 206 | EditorState.readOnly.of(props.readOnly), |
| 207 | EditorView.editable.of(!props.readOnly), |
| 208 | EditorView.contentAttributes.of({ |
| 209 | role: "textbox", |
| 210 | "aria-label": props.relativePath, |
| 211 | "aria-multiline": "true", |
| 212 | "aria-readonly": String(props.readOnly), |
| 213 | tabindex: "0", |
| 214 | }), |
| 215 | Prec.highest( |
| 216 | EditorView.domEventHandlers({ |
| 217 | click(event, view) { |
| 218 | if ( |
| 219 | !(props.readOnly || event.metaKey || event.ctrlKey) || |
| 220 | !(event.target instanceof Node) |
| 221 | ) |
| 222 | return false; |
| 223 | return activateLink(view, view.posAtDOM(event.target)); |
| 224 | }, |
| 225 | keydown(event, view) { |
| 226 | if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { |
| 227 | return activateLink(view, view.state.selection.main.head); |
| 228 | } |
| 229 | if (event.key !== "Escape" || !view.hasFocus) return false; |
| 230 | view.contentDOM.blur(); |
| 231 | return false; |
| 232 | }, |
| 233 | }), |
| 234 | ), |
| 235 | ]), |
| 236 | ], |
| 237 | }); |
| 238 | }, [ |
| 239 | activateLink, |
| 240 | editing, |
| 241 | presentation, |
| 242 | props.readOnly, |
| 243 | props.relativePath, |
| 244 | resolveUrl, |
| 245 | renderVideo, |
| 246 | ]); |
| 247 | |
| 248 | useEffect(() => { |
| 249 | const view = editor.current?.view; |
| 250 | if (!view || view.state.doc.toString() === props.text) return; |
| 251 | view.dispatch({ |
| 252 | changes: { from: 0, to: view.state.doc.length, insert: props.text }, |
| 253 | selection: { |
| 254 | anchor: Math.min(view.state.selection.main.anchor, props.text.length), |
| 255 | head: Math.min(view.state.selection.main.head, props.text.length), |
| 256 | }, |
| 257 | annotations: Transaction.addToHistory.of(false), |
| 258 | }); |
| 259 | }, [props.text]); |
| 260 | |
| 261 | return ( |
| 262 | <> |
| 263 | <div ref={host} className="min-h-0 flex-1 overflow-hidden" data-file-markdown-editor /> |
| 264 | {portals} |
| 265 | </> |
| 266 | ); |
| 267 | } |