u
This commit is contained in:
386
src/components/vfs/VfsTextEditor.jsx
Normal file
386
src/components/vfs/VfsTextEditor.jsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import SaveRoundedIcon from '@mui/icons-material/SaveRounded';
|
||||
import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
|
||||
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||
import WrapTextRoundedIcon from '@mui/icons-material/WrapTextRounded';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
import VfsFileIcon from './VfsFileIcon';
|
||||
import { formatVfsSize } from './vfsUtils';
|
||||
|
||||
export default class VfsTextEditor extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.textareaRef = React.createRef();
|
||||
this.gutterRef = React.createRef();
|
||||
this.state = {
|
||||
wrapLines: true,
|
||||
cursorLine: 1,
|
||||
cursorCol: 1,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.fileName !== this.props.fileName) {
|
||||
this.focusEditor();
|
||||
}
|
||||
}
|
||||
|
||||
focusEditor = () => {
|
||||
setTimeout(() => {
|
||||
this.textareaRef.current?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
handleScroll = () => {
|
||||
if (this.textareaRef.current && this.gutterRef.current) {
|
||||
this.gutterRef.current.scrollTop = this.textareaRef.current.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
toggleWrap = () => {
|
||||
this.setState((prev) => ({ wrapLines: !prev.wrapLines }));
|
||||
};
|
||||
|
||||
handleCursorMove = () => {
|
||||
const ta = this.textareaRef.current;
|
||||
if (!ta) return;
|
||||
const pos = ta.selectionStart || 0;
|
||||
const textBefore = (this.props.content || '').substring(0, pos);
|
||||
const lines = textBefore.split('\n');
|
||||
const line = lines.length;
|
||||
const col = lines[lines.length - 1].length + 1;
|
||||
if (this.state.cursorLine !== line || this.state.cursorCol !== col) {
|
||||
this.setState({ cursorLine: line, cursorCol: col });
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
// Ctrl+S or Cmd+S -> Save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
if (this.props.isDirty && !this.props.isSaving) {
|
||||
this.props.onSave();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab key -> Insert 2 spaces
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const ta = this.textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const val = this.props.content;
|
||||
const next = val.substring(0, start) + ' ' + val.substring(end);
|
||||
this.props.onChange(next);
|
||||
setTimeout(() => {
|
||||
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
handleRevert = () => {
|
||||
if (window.confirm('Möchtest du alle ungespeicherten Änderungen verwerfen?')) {
|
||||
this.props.onChange(this.props.originalContent);
|
||||
}
|
||||
};
|
||||
|
||||
renderLineNumbers(lineCount) {
|
||||
const lines = [];
|
||||
for (let i = 1; i <= lineCount; i++) {
|
||||
lines.push(
|
||||
<div key={i} style={{ height: '21px', lineHeight: '21px' }}>
|
||||
{i}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
fileName,
|
||||
file,
|
||||
content,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveError,
|
||||
loadingContent,
|
||||
onSave,
|
||||
onClose,
|
||||
onDownload,
|
||||
onMentionInChat,
|
||||
} = this.props;
|
||||
|
||||
const lineCount = (content || '').split('\n').length;
|
||||
const charCount = (content || '').length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? '#14171a' : '#f8f9fa',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Editor Top Bar */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
px: 2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
bgcolor: 'background.paper',
|
||||
flexShrink: 0,
|
||||
minHeight: 48,
|
||||
}}
|
||||
>
|
||||
{/* File Name & Status */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}>
|
||||
<Tooltip title="Zurück zur Dateiliste">
|
||||
<IconButton size="small" onClick={onClose} sx={{ p: 0.5, mr: 0.25 }}>
|
||||
<ArrowBackRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
noWrap
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
>
|
||||
{fileName}
|
||||
</Typography>
|
||||
|
||||
{isDirty ? (
|
||||
<Chip
|
||||
label="Ungespeichert"
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, borderRadius: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label="Gespeichert"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.68rem', borderRadius: 1, opacity: 0.7 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||
{onMentionInChat && (
|
||||
<Tooltip title="In Chat erwähnen">
|
||||
<IconButton size="small" onClick={onMentionInChat} sx={{ p: 0.6 }}>
|
||||
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Herunterladen">
|
||||
<IconButton size="small" onClick={onDownload} sx={{ p: 0.6 }}>
|
||||
<DownloadRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{isDirty && (
|
||||
<Tooltip title="Änderungen verwerfen">
|
||||
<IconButton size="small" onClick={this.handleRevert} sx={{ p: 0.6, color: 'text.secondary' }}>
|
||||
<UndoRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={this.state.wrapLines ? 'Zeilenumbruch ausschalten' : 'Zeilenumbruch einschalten'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.toggleWrap}
|
||||
color={this.state.wrapLines ? 'primary' : 'default'}
|
||||
sx={{
|
||||
p: 0.6,
|
||||
bgcolor: this.state.wrapLines ? 'action.selected' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<WrapTextRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color={isDirty ? 'primary' : 'inherit'}
|
||||
startIcon={isSaving ? <CircularProgress size={14} color="inherit" /> : <SaveRoundedIcon />}
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.4,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isSaving ? 'Speichern…' : 'Speichern (Strg+S)'}
|
||||
</Button>
|
||||
|
||||
<Tooltip title="Editor schließen">
|
||||
<IconButton size="small" onClick={onClose} sx={{ p: 0.6, ml: 0.5 }}>
|
||||
<CloseRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error notification if save failed */}
|
||||
{saveError && (
|
||||
<Alert severity="error" sx={{ py: 0.5, px: 2, borderRadius: 0, fontSize: '0.78rem' }}>
|
||||
{saveError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Editor Area */}
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', minHeight: 0, position: 'relative' }}>
|
||||
{loadingContent ? (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
<Typography variant="body2">Lade Dateiinhalt…</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{/* Line Numbers Gutter (shown when wrap is off to maintain line alignment) */}
|
||||
{!this.state.wrapLines && (
|
||||
<Box
|
||||
ref={this.gutterRef}
|
||||
sx={(theme) => ({
|
||||
width: 44,
|
||||
flexShrink: 0,
|
||||
bgcolor: theme.palette.mode === 'dark' ? '#0f1215' : '#f0f2f5',
|
||||
color: 'text.disabled',
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
fontSize: '13px',
|
||||
textAlign: 'right',
|
||||
pr: 1.2,
|
||||
pt: 1.5,
|
||||
pb: 3,
|
||||
userSelect: 'none',
|
||||
overflow: 'hidden',
|
||||
borderRight: 1,
|
||||
borderColor: 'divider',
|
||||
})}
|
||||
>
|
||||
{this.renderLineNumbers(lineCount)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Textarea Code Input */}
|
||||
<Box
|
||||
component="textarea"
|
||||
ref={this.textareaRef}
|
||||
value={content || ''}
|
||||
onChange={(e) => {
|
||||
this.props.onChange(e.target.value);
|
||||
this.handleCursorMove();
|
||||
}}
|
||||
onClick={this.handleCursorMove}
|
||||
onKeyUp={this.handleCursorMove}
|
||||
onSelect={this.handleCursorMove}
|
||||
onScroll={this.handleScroll}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
wrap={this.state.wrapLines ? 'soft' : 'off'}
|
||||
sx={(theme) => ({
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
p: 1.5,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
bgcolor: 'transparent',
|
||||
color: 'text.primary',
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
fontSize: '13px',
|
||||
lineHeight: '21px',
|
||||
tabSize: 2,
|
||||
whiteSpace: this.state.wrapLines ? 'pre-wrap' : 'pre',
|
||||
wordBreak: this.state.wrapLines ? 'break-word' : 'normal',
|
||||
overflowWrap: this.state.wrapLines ? 'anywhere' : 'normal',
|
||||
overflowX: this.state.wrapLines ? 'hidden' : 'auto',
|
||||
overflowY: 'auto',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Status Bar */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.4,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '0.72rem',
|
||||
color: 'text.secondary',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<span>
|
||||
Zeile {this.state.cursorLine}, Spalte {this.state.cursorCol}
|
||||
</span>
|
||||
<span>{lineCount} Zeilen</span>
|
||||
<span>{charCount} Zeichen</span>
|
||||
<span>{formatVfsSize(charCount)}</span>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<span>UTF-8</span>
|
||||
<span>{file?.mime || 'text/plain'}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user