feat(editor): integrate Monaco Editor for local file editing and enhance language inference

This commit is contained in:
Anish Sarkar 2026-04-23 18:00:51 +05:30
parent 864f6f798a
commit bbc1c76c0d
5 changed files with 166 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { VersionHistoryButton } from "@/components/documents/version-history";
import { LocalFileMonaco } from "@/components/editor/local-file-monaco";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@ -14,6 +15,7 @@ import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/u
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
@ -77,6 +79,7 @@ export function EditorPanelContent({
const [downloading, setDownloading] = useState(false);
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const [localFileContent, setLocalFileContent] = useState("");
const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false);
const changeCountRef = useRef(0);
@ -91,6 +94,7 @@ export function EditorPanelContent({
setError(null);
setEditorDoc(null);
setEditedMarkdown(null);
setLocalFileContent("");
initialLoadDone.current = false;
changeCountRef.current = 0;
@ -115,6 +119,7 @@ export function EditorPanelContent({
source_markdown: readResult.content,
};
markdownRef.current = content.source_markdown;
setLocalFileContent(content.source_markdown);
setDisplayTitle(title || inferredTitle);
setEditorDoc(content);
initialLoadDone.current = true;
@ -244,6 +249,7 @@ export function EditorPanelContent({
? (isLocalFileMode || EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
!isLargeDocument
: false;
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
return (
<>
@ -348,6 +354,20 @@ export function EditorPanelContent({
</Alert>
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : isLocalFileMode ? (
<div className="h-full overflow-hidden">
<LocalFileMonaco
filePath={localFilePath ?? "local-file.txt"}
language={localFileLanguage}
value={localFileContent}
onChange={(next) => {
markdownRef.current = next;
setLocalFileContent(next);
if (!initialLoadDone.current) return;
setEditedMarkdown(next === (editorDoc?.source_markdown ?? "") ? null : next);
}}
/>
</div>
) : isEditableType ? (
<PlateEditor
key={isLocalFileMode ? localFilePath ?? "local-file" : documentId}

View file

@ -0,0 +1,56 @@
"use client";
import dynamic from "next/dynamic";
import { useTheme } from "next-themes";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
});
interface LocalFileMonacoProps {
filePath: string;
language: string;
value: string;
onChange: (next: string) => void;
}
export function LocalFileMonaco({ filePath, language, value, onChange }: LocalFileMonacoProps) {
const { resolvedTheme } = useTheme();
return (
<div className="h-full w-full overflow-hidden bg-sidebar">
<MonacoEditor
path={filePath}
language={language}
value={value}
theme={resolvedTheme === "dark" ? "vs-dark" : "vs"}
onChange={(next) => onChange(next ?? "")}
options={{
automaticLayout: true,
minimap: { enabled: false },
lineNumbers: "on",
lineNumbersMinChars: 3,
lineDecorationsWidth: 12,
glyphMargin: false,
folding: true,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
scrollBeyondLastLine: false,
wordWrap: "off",
scrollbar: {
vertical: "hidden",
horizontal: "hidden",
alwaysConsumeMouseWheel: false,
},
tabSize: 2,
insertSpaces: true,
fontSize: 12,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
renderWhitespace: "selection",
smoothScrolling: true,
}}
/>
</div>
);
}

View file

@ -0,0 +1,34 @@
const EXTENSION_TO_MONACO_LANGUAGE: Record<string, string> = {
css: "css",
csv: "plaintext",
cjs: "javascript",
html: "html",
htm: "html",
ini: "ini",
js: "javascript",
json: "json",
markdown: "markdown",
md: "markdown",
mjs: "javascript",
py: "python",
sql: "sql",
toml: "plaintext",
ts: "typescript",
tsx: "typescript",
xml: "xml",
yaml: "yaml",
yml: "yaml",
};
export function inferMonacoLanguageFromPath(filePath: string | null | undefined): string {
if (!filePath) return "plaintext";
const fileName = filePath.split("/").pop() ?? filePath;
const extensionIndex = fileName.lastIndexOf(".");
if (extensionIndex <= 0 || extensionIndex === fileName.length - 1) {
return "plaintext";
}
const extension = fileName.slice(extensionIndex + 1).toLowerCase();
return EXTENSION_TO_MONACO_LANGUAGE[extension] ?? "plaintext";
}

View file

@ -28,6 +28,7 @@
"@babel/standalone": "^7.29.2",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.5.0",
"@monaco-editor/react": "^4.7.0",
"@number-flow/react": "^0.5.10",
"@platejs/autoformat": "^52.0.11",
"@platejs/basic-nodes": "^52.0.11",
@ -106,6 +107,7 @@
"lenis": "^1.3.17",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"motion": "^12.23.22",
"next": "^16.1.0",
"next-intl": "^4.6.1",

View file

@ -29,6 +29,9 @@ importers:
'@marsidev/react-turnstile':
specifier: ^1.5.0
version: 1.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@number-flow/react':
specifier: ^0.5.10
version: 0.5.14(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -263,6 +266,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
motion:
specifier: ^12.23.22
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1980,6 +1986,16 @@ packages:
peerDependencies:
mediabunny: ^1.0.0
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
'@monaco-editor/react@4.7.0':
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@napi-rs/canvas-android-arm64@0.1.97':
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
engines: {node: '>= 10'}
@ -5368,6 +5384,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
@ -6745,6 +6764,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
@ -6965,6 +6989,9 @@ packages:
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
motion-dom@12.34.3:
resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==}
@ -7943,6 +7970,9 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@ -10050,6 +10080,17 @@ snapshots:
dependencies:
mediabunny: 1.39.2
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@monaco-editor/loader': 1.7.0
monaco-editor: 0.55.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@napi-rs/canvas-android-arm64@0.1.97':
optional: true
@ -13748,6 +13789,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
@ -15327,6 +15372,8 @@ snapshots:
markdown-table@3.0.4: {}
marked@14.0.0: {}
marked@15.0.12: {}
marked@17.0.3: {}
@ -15822,6 +15869,11 @@ snapshots:
module-details-from-path@1.0.4: {}
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
motion-dom@12.34.3:
dependencies:
motion-utils: 12.29.2
@ -17073,6 +17125,8 @@ snapshots:
stable-hash@0.0.5: {}
state-local@1.0.7: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0