feat: fullscreen file (#228)
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
|||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiFolder3Fill,
|
RiFolder3Fill,
|
||||||
RiFolderOpenFill,
|
RiFolderOpenFill,
|
||||||
|
RiFullscreenExitLine,
|
||||||
|
RiFullscreenLine,
|
||||||
RiLoader4Line,
|
RiLoader4Line,
|
||||||
RiRefreshLine,
|
RiRefreshLine,
|
||||||
RiSearchLine,
|
RiSearchLine,
|
||||||
@@ -261,6 +263,7 @@ export const FilesView: React.FC = () => {
|
|||||||
|
|
||||||
const [showMobilePageContent, setShowMobilePageContent] = React.useState(false);
|
const [showMobilePageContent, setShowMobilePageContent] = React.useState(false);
|
||||||
const [wrapLines, setWrapLines] = React.useState(isMobile);
|
const [wrapLines, setWrapLines] = React.useState(isMobile);
|
||||||
|
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||||
|
|
||||||
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
|
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
|
||||||
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
|
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
|
||||||
@@ -1510,6 +1513,26 @@ export const FilesView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedFile && !isMobile && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<RiFullscreenExitLine className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<RiFullscreenLine className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1756,9 +1779,200 @@ export const FilesView: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fullscreen file viewer overlay
|
||||||
|
const fullscreenViewer = isFullscreen && selectedFile && (
|
||||||
|
<div className="absolute inset-0 z-50 flex flex-col bg-background">
|
||||||
|
{/* Fullscreen header */}
|
||||||
|
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-4 py-2 flex-shrink-0">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="typography-ui-label font-medium truncate">
|
||||||
|
{selectedFile.name}
|
||||||
|
</div>
|
||||||
|
<div className="typography-meta text-muted-foreground truncate" title={displaySelectedPath}>
|
||||||
|
{displaySelectedPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void saveDraft()}
|
||||||
|
disabled={!isDirty || isSaving}
|
||||||
|
className="h-6 w-6 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
|
||||||
|
title={`Save (${getModifierLabel()}+S)`}
|
||||||
|
aria-label={`Save (${getModifierLabel()}+S)`}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RiSave3Line className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && !isSelectedImage && (
|
||||||
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSelectedImage && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setWrapLines(!wrapLines)}
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 p-0 transition-opacity',
|
||||||
|
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
||||||
|
)}
|
||||||
|
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
|
||||||
|
>
|
||||||
|
<RiTextWrap className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(canCopy || canCopyPath || isMarkdownFile(selectedFile.path)) && (canEdit || !isSelectedImage) && (
|
||||||
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMarkdownFile(selectedFile.path) && (
|
||||||
|
<PreviewToggleButton
|
||||||
|
currentMode={getMdViewMode()}
|
||||||
|
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCopy && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(fileContent);
|
||||||
|
setCopiedContent(true);
|
||||||
|
if (copiedContentTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
||||||
|
}
|
||||||
|
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setCopiedContent(false);
|
||||||
|
}, 1200);
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="Copy file contents"
|
||||||
|
aria-label="Copy file contents"
|
||||||
|
>
|
||||||
|
{copiedContent ? (
|
||||||
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||||
|
) : (
|
||||||
|
<RiClipboardLine className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCopyPath && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(displaySelectedPath);
|
||||||
|
setCopiedPath(true);
|
||||||
|
if (copiedPathTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(copiedPathTimeoutRef.current);
|
||||||
|
}
|
||||||
|
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setCopiedPath(false);
|
||||||
|
}, 1200);
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title={`Copy file path (${displaySelectedPath})`}
|
||||||
|
aria-label={`Copy file path (${displaySelectedPath})`}
|
||||||
|
>
|
||||||
|
{copiedPath ? (
|
||||||
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||||
|
) : (
|
||||||
|
<RiFileCopy2Line className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="Exit fullscreen"
|
||||||
|
aria-label="Exit fullscreen"
|
||||||
|
>
|
||||||
|
<RiFullscreenExitLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen content */}
|
||||||
|
<div className="flex-1 min-h-0 min-w-0 relative">
|
||||||
|
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||||
|
{fileLoading ? (
|
||||||
|
<div className="p-4 flex items-center gap-2 typography-ui text-muted-foreground">
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : fileError ? (
|
||||||
|
<div className="p-4 typography-ui text-[color:var(--status-error)]">{fileError}</div>
|
||||||
|
) : isSelectedImage ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={selectedFile.name}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
{fileContent.length > 500 * 1024 && (
|
||||||
|
<div className="mb-3 rounded-md border border-warning/20 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||||
|
This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={
|
||||||
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
||||||
|
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Switch to edit mode to fix the issue.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SimpleMarkdownRenderer content={fileContent} className="typography-markdown-body" />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full">
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={draftContent}
|
||||||
|
onChange={setDraftContent}
|
||||||
|
extensions={editorExtensions}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 overflow-hidden bg-background">
|
<div className="flex h-full min-h-0 overflow-hidden bg-background relative">
|
||||||
{renderDialogs()}
|
{renderDialogs()}
|
||||||
|
{fullscreenViewer}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
showMobilePageContent ? (
|
showMobilePageContent ? (
|
||||||
fileViewer
|
fileViewer
|
||||||
|
|||||||
Reference in New Issue
Block a user