mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-02 21:12:13 +08:00
334 lines
8.7 KiB
TypeScript
334 lines
8.7 KiB
TypeScript
import {
|
|
CloseFullscreenRounded,
|
|
ContentPasteRounded,
|
|
FormatPaintRounded,
|
|
OpenInFullRounded,
|
|
RestartAltRounded,
|
|
} from '@mui/icons-material'
|
|
import {
|
|
Button,
|
|
ButtonGroup,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
IconButton,
|
|
} from '@mui/material'
|
|
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
|
import { useLockFn } from 'ahooks'
|
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import { BaseLoadingOverlay, MonacoEditor } from '@/components/base'
|
|
import { showNotice } from '@/services/notice-service'
|
|
import { useThemeMode } from '@/services/states'
|
|
import type { MonacoEditorInstance, MonacoMarker } from '@/types/monaco'
|
|
import debounce from '@/utils/debounce'
|
|
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
|
|
|
const appWindow = getCurrentWebviewWindow()
|
|
|
|
type EditorLanguage = 'yaml' | 'javascript' | 'css'
|
|
|
|
export interface EditorViewerProps {
|
|
open: boolean
|
|
title?: string | ReactNode
|
|
description?: ReactNode
|
|
value: string
|
|
language: EditorLanguage
|
|
path: string
|
|
readOnly?: boolean
|
|
loading?: boolean
|
|
dirty?: boolean
|
|
saveDisabled?: boolean
|
|
onChange?: (value: string) => void
|
|
onSave?: () => void | Promise<void>
|
|
onResetToDefault?: () => void
|
|
onClose: () => void
|
|
onValidate?: (markers: MonacoMarker[]) => void
|
|
}
|
|
|
|
export const EditorViewer = ({
|
|
open,
|
|
title,
|
|
description,
|
|
value,
|
|
language,
|
|
path,
|
|
readOnly = false,
|
|
loading = false,
|
|
dirty,
|
|
saveDisabled = false,
|
|
onChange,
|
|
onSave,
|
|
onResetToDefault,
|
|
onClose,
|
|
onValidate,
|
|
}: EditorViewerProps) => {
|
|
const { t } = useTranslation()
|
|
const themeMode = useThemeMode()
|
|
const [isMaximized, setIsMaximized] = useState(false)
|
|
const editorRef = useRef<MonacoEditorInstance | null>(null)
|
|
|
|
const resolvedTitle = title ?? t('profiles.components.menu.editFile')
|
|
const disableSave = loading || saveDisabled || dirty === false
|
|
|
|
const syncEditorValue = useCallback(() => {
|
|
const model = editorRef.current?.getModel()
|
|
if (model && model.getValue() !== value) {
|
|
model.setValue(value)
|
|
}
|
|
}, [value])
|
|
|
|
const syncMaximizedState = useCallback(async () => {
|
|
try {
|
|
setIsMaximized(await appWindow.isMaximized())
|
|
} catch {
|
|
setIsMaximized(false)
|
|
}
|
|
}, [])
|
|
|
|
const handleSave = useLockFn(async () => {
|
|
try {
|
|
if (!readOnly) {
|
|
await onSave?.()
|
|
}
|
|
onClose()
|
|
} catch (error) {
|
|
showNotice.error(error)
|
|
}
|
|
})
|
|
|
|
const handleClose = () => {
|
|
try {
|
|
onClose()
|
|
} catch (error) {
|
|
showNotice.error(error)
|
|
}
|
|
}
|
|
|
|
const handlePaste = useLockFn(async () => {
|
|
try {
|
|
if (readOnly || loading || !editorRef.current) return
|
|
|
|
const text = await navigator.clipboard.readText()
|
|
if (!text) return
|
|
|
|
const editorInstance = editorRef.current
|
|
const model = editorInstance.getModel()
|
|
const selections = editorInstance.getSelections()
|
|
if (!model || !selections || selections.length === 0) return
|
|
|
|
editorInstance.pushUndoStop()
|
|
editorInstance.executeEdits(
|
|
'explicit-paste',
|
|
selections.map((selection) => ({
|
|
range: selection,
|
|
text,
|
|
forceMoveMarkers: true,
|
|
})),
|
|
)
|
|
editorInstance.pushUndoStop()
|
|
editorInstance.focus()
|
|
} catch (error) {
|
|
showNotice.error(error)
|
|
}
|
|
})
|
|
|
|
const handleFormat = useLockFn(async () => {
|
|
try {
|
|
if (loading) return
|
|
await editorRef.current?.getAction('editor.action.formatDocument')?.run()
|
|
} catch (error) {
|
|
showNotice.error(error)
|
|
}
|
|
})
|
|
|
|
const handleToggleMaximize = useLockFn(async () => {
|
|
try {
|
|
await appWindow.toggleMaximize()
|
|
await syncMaximizedState()
|
|
editorRef.current?.layout()
|
|
} catch (error) {
|
|
showNotice.error(error)
|
|
}
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
void syncMaximizedState()
|
|
}, [open, syncMaximizedState])
|
|
|
|
useEffect(() => {
|
|
if (!open || loading) return
|
|
syncEditorValue()
|
|
}, [loading, open, syncEditorValue])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
const onResized = debounce(() => {
|
|
void syncMaximizedState()
|
|
try {
|
|
editorRef.current?.layout()
|
|
} catch {
|
|
// Ignore transient layout errors during window transitions.
|
|
}
|
|
}, 100)
|
|
|
|
const unlistenResized = appWindow.onResized(onResized)
|
|
|
|
return () => {
|
|
unlistenResized.then((unlisten) => unlisten())
|
|
}
|
|
}, [open, syncMaximizedState])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
editorRef.current?.dispose()
|
|
editorRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={handleClose}
|
|
maxWidth="xl"
|
|
fullWidth
|
|
disableEnforceFocus
|
|
>
|
|
<DialogTitle>{resolvedTitle}</DialogTitle>
|
|
|
|
<DialogContent
|
|
sx={{
|
|
width: 'auto',
|
|
height: 'calc(100vh - 185px)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{description}
|
|
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 0 }}>
|
|
<BaseLoadingOverlay isLoading={loading} />
|
|
{!loading && (
|
|
<MonacoEditor
|
|
height="100%"
|
|
path={path}
|
|
value={value}
|
|
language={language}
|
|
theme={themeMode === 'light' ? 'light' : 'vs-dark'}
|
|
loading={null}
|
|
saveViewState
|
|
keepCurrentModel={false}
|
|
onMount={(editorInstance) => {
|
|
editorRef.current = editorInstance
|
|
syncEditorValue()
|
|
}}
|
|
onChange={(nextValue) => onChange?.(nextValue ?? '')}
|
|
onValidate={onValidate}
|
|
options={{
|
|
automaticLayout: true,
|
|
tabSize: 2,
|
|
minimap: {
|
|
enabled:
|
|
typeof document !== 'undefined' &&
|
|
document.documentElement.clientWidth >= 1500,
|
|
},
|
|
mouseWheelZoom: true,
|
|
readOnly,
|
|
readOnlyMessage: {
|
|
value: t('profiles.modals.editor.messages.readOnly'),
|
|
},
|
|
renderValidationDecorations: 'on',
|
|
quickSuggestions: {
|
|
strings: true,
|
|
comments: true,
|
|
other: true,
|
|
},
|
|
padding: {
|
|
top: 33,
|
|
},
|
|
fontFamily: MONACO_FONT_FAMILY,
|
|
fontLigatures: false,
|
|
smoothScrolling: true,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<ButtonGroup
|
|
variant="contained"
|
|
sx={{ position: 'absolute', left: '14px', bottom: '8px' }}
|
|
>
|
|
<IconButton
|
|
size="medium"
|
|
color="inherit"
|
|
sx={{ display: readOnly ? 'none' : '' }}
|
|
title={t('profiles.page.importForm.actions.paste')}
|
|
disabled={loading}
|
|
onClick={() => {
|
|
void handlePaste()
|
|
}}
|
|
>
|
|
<ContentPasteRounded fontSize="inherit" />
|
|
</IconButton>
|
|
<IconButton
|
|
size="medium"
|
|
color="inherit"
|
|
sx={{ display: readOnly ? 'none' : '' }}
|
|
title={t('profiles.modals.editor.actions.format')}
|
|
disabled={loading}
|
|
onClick={() => {
|
|
void handleFormat()
|
|
}}
|
|
>
|
|
<FormatPaintRounded fontSize="inherit" />
|
|
</IconButton>
|
|
<IconButton
|
|
size="medium"
|
|
color="inherit"
|
|
title={t(
|
|
isMaximized ? 'shared.window.minimize' : 'shared.window.maximize',
|
|
)}
|
|
onClick={() => {
|
|
void handleToggleMaximize()
|
|
}}
|
|
>
|
|
{isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />}
|
|
</IconButton>
|
|
</ButtonGroup>
|
|
</DialogContent>
|
|
|
|
<DialogActions>
|
|
{!readOnly && onResetToDefault && (
|
|
<Button
|
|
onClick={onResetToDefault}
|
|
variant="outlined"
|
|
color="warning"
|
|
startIcon={<RestartAltRounded />}
|
|
disabled={loading}
|
|
>
|
|
{t('shared.actions.resetToDefault')}
|
|
</Button>
|
|
)}
|
|
<Button onClick={handleClose} variant="outlined">
|
|
{t(readOnly ? 'shared.actions.close' : 'shared.actions.cancel')}
|
|
</Button>
|
|
{!readOnly && (
|
|
<Button
|
|
onClick={() => {
|
|
void handleSave()
|
|
}}
|
|
variant="contained"
|
|
disabled={disableSave}
|
|
>
|
|
{t('shared.actions.save')}
|
|
</Button>
|
|
)}
|
|
</DialogActions>
|
|
</Dialog>
|
|
)
|
|
}
|