1import { createMarkdownEditor, livePreview, type MarkdownEditor } from "@clo/markdown-editor";
2import { syntaxTree } from "@codemirror/language";
3import { Compartment, EditorState, Prec, Transaction } from "@codemirror/state";
4import { EditorView } from "@codemirror/view";
5import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts";
6import {
7 classifyMarkdownImageSource,
8 markdownImageSourceFragment,
9} from "@t3tools/client-runtime/markdown-images";
10import { workspaceRelativeFilePath } from "@t3tools/shared/path";
11import { resolveMarkdownFileLinkTarget } from "@t3tools/shared/markdownLinks";
12import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks";
13import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
14
15import { useOpenLink } from "~/browser/useOpenLink";
16import { useRightPanelStore } from "~/rightPanelStore";
17import { toastManager } from "~/components/ui/toast";
18import { useAssetUrls } from "~/assets/assetUrls";
19import { composerCodeBlockHighlight } from "~/components/composerCodeBlockHighlight";
20import { workspaceAssetResource } from "./filePreviewMode";
21import { useMarkdownVideos } from "./FileMarkdownVideos";
22
23export 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}