mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-02-06 03:30:25 -05:00
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
'use client';
|
|
|
|
import { Editor, type Monaco, type OnMount } from '@monaco-editor/react';
|
|
import { Button } from '@workspace/ui/components/button';
|
|
import { cn } from '@workspace/ui/lib/utils';
|
|
import { useSize } from 'ahooks';
|
|
import { EyeIcon, EyeOff, FullscreenIcon, MinimizeIcon } from 'lucide-react';
|
|
import DraculaTheme from 'monaco-themes/themes/Dracula.json' with { type: 'json' };
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
export interface MonacoEditorProps {
|
|
value?: string;
|
|
onChange?: (value: string | undefined) => void;
|
|
onBlur?: (value: string | undefined) => void;
|
|
title?: string;
|
|
description?: string;
|
|
placeholder?: string;
|
|
render?: (value?: string) => React.ReactNode;
|
|
onMount?: OnMount;
|
|
beforeMount?: (monaco: Monaco) => void;
|
|
language?: string;
|
|
className?: string;
|
|
showLineNumbers?: boolean;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function debounce<T extends (...args: any[]) => void>(func: T, delay: number) {
|
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
return function (...args: Parameters<T>) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => func(...args), delay);
|
|
};
|
|
}
|
|
|
|
export function MonacoEditor({
|
|
value: propValue,
|
|
onChange,
|
|
onBlur,
|
|
title = 'Editor Title',
|
|
description,
|
|
placeholder = 'Start typing...',
|
|
render,
|
|
onMount,
|
|
beforeMount,
|
|
language = 'markdown',
|
|
className,
|
|
showLineNumbers = false,
|
|
}: MonacoEditorProps) {
|
|
const [internalValue, setInternalValue] = useState<string | undefined>(propValue);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const size = useSize(ref);
|
|
|
|
useEffect(() => {
|
|
setInternalValue(propValue);
|
|
}, [propValue]);
|
|
|
|
const debouncedOnChange = useRef(
|
|
debounce((newValue: string | undefined) => {
|
|
if (onChange) {
|
|
onChange(newValue);
|
|
}
|
|
}, 300),
|
|
).current;
|
|
|
|
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
|
if (onMount) onMount(editor, monaco);
|
|
|
|
editor.onDidChangeModelContent(() => {
|
|
const newValue = editor.getValue();
|
|
setInternalValue(newValue);
|
|
debouncedOnChange(newValue);
|
|
});
|
|
|
|
editor.onDidBlurEditorWidget(() => {
|
|
if (onBlur) {
|
|
onBlur(editor.getValue());
|
|
}
|
|
});
|
|
};
|
|
|
|
const toggleFullscreen = () => setIsFullscreen(!isFullscreen);
|
|
const togglePreview = () => setIsPreviewVisible(!isPreviewVisible);
|
|
|
|
return (
|
|
<div ref={ref} className='size-full'>
|
|
<div style={size}>
|
|
<div
|
|
className={cn('flex size-full min-h-96 flex-col rounded-md border', className, {
|
|
'bg-background fixed inset-0 z-50 !mt-0 h-screen': isFullscreen,
|
|
})}
|
|
>
|
|
<div className='flex items-center justify-between border-b p-2'>
|
|
<div>
|
|
<h1 className='text-left text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'>
|
|
{title}
|
|
</h1>
|
|
<p className='text-muted-foreground text-[0.8rem]'>{description}</p>
|
|
</div>
|
|
|
|
<div className='flex items-center space-x-2'>
|
|
{render && (
|
|
<Button variant='outline' size='icon' type='button' onClick={togglePreview}>
|
|
{isPreviewVisible ? <EyeOff /> : <EyeIcon />}
|
|
</Button>
|
|
)}
|
|
<Button variant='outline' size='icon' type='button' onClick={toggleFullscreen}>
|
|
{isFullscreen ? <MinimizeIcon /> : <FullscreenIcon />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn('relative flex flex-1 overflow-hidden')}>
|
|
<div
|
|
className={cn('flex-1 overflow-hidden p-4 invert dark:invert-0', {
|
|
'w-1/2': isPreviewVisible,
|
|
})}
|
|
>
|
|
<Editor
|
|
language={language}
|
|
value={internalValue}
|
|
onChange={(newValue) => {
|
|
setInternalValue(newValue);
|
|
debouncedOnChange(newValue);
|
|
}}
|
|
onMount={handleEditorDidMount}
|
|
className=''
|
|
options={{
|
|
automaticLayout: true,
|
|
contextmenu: false,
|
|
folding: false,
|
|
fontSize: 14,
|
|
formatOnPaste: true,
|
|
formatOnType: true,
|
|
glyphMargin: false,
|
|
lineNumbers: showLineNumbers ? 'on' : 'off',
|
|
minimap: { enabled: false },
|
|
overviewRulerLanes: 0,
|
|
renderLineHighlight: 'none',
|
|
scrollBeyondLastLine: false,
|
|
scrollbar: {
|
|
useShadows: false,
|
|
vertical: 'hidden',
|
|
},
|
|
tabSize: 2,
|
|
wordWrap: 'off',
|
|
}}
|
|
theme='transparentTheme'
|
|
beforeMount={(monaco: Monaco) => {
|
|
monaco.editor.defineTheme('transparentTheme', {
|
|
base: DraculaTheme.base as 'vs' | 'vs-dark' | 'hc-black',
|
|
inherit: DraculaTheme.inherit,
|
|
rules: DraculaTheme.rules,
|
|
colors: {
|
|
...DraculaTheme.colors,
|
|
'editor.background': '#00000000',
|
|
},
|
|
});
|
|
if (beforeMount) {
|
|
beforeMount(monaco);
|
|
}
|
|
}}
|
|
/>
|
|
{!internalValue?.trim() && placeholder && (
|
|
<pre
|
|
className='text-muted-foreground pointer-events-none absolute left-7 top-4 text-sm'
|
|
style={{ userSelect: 'none' }}
|
|
>
|
|
{placeholder}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
{render && isPreviewVisible && (
|
|
<div className='w-1/2 flex-1 overflow-auto border-l p-4'>{render(internalValue)}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|