This commit is contained in:
seb
2026-10-07 13:10:23 +02:00
parent e4927f5d8a
commit 6c0b1503e8
14 changed files with 2644 additions and 86 deletions

View 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>
);
}
}