feat(files): add HTML preview support in file viewer (#772)

- Add isHtmlFile() helper to detect .html/.htm files
- Add htmlViewMode state for preview/edit toggle
- Add localStorage persistence for HTML view mode preference
- Show PreviewToggleButton for HTML files
- Render HTML in sandboxed iframe with srcdoc
- Inject base tag for relative CSS/JS/image paths to work
- Sandbox permissions: allow-scripts, same-origin, forms
- Reset view mode when switching files
This commit is contained in:
Nguyễn Ngô Thượng
2026-04-01 15:33:17 +03:00
committed by GitHub
parent f032df2298
commit 744b29c492
+62 -4
View File
@@ -263,6 +263,12 @@ const isMarkdownFile = (path: string): boolean => {
return ext === 'md' || ext === 'markdown';
};
const isHtmlFile = (path: string): boolean => {
if (!path) return false;
const ext = path.toLowerCase().split('.').pop();
return ext === 'html' || ext === 'htm';
};
interface FileRowProps {
node: FileNode;
isExpanded: boolean;
@@ -466,6 +472,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
const [htmlViewMode, setHtmlViewMode] = React.useState<'preview' | 'edit'>('edit');
const lightTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
@@ -1632,8 +1639,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
const isTextFile = Boolean(selectedFile && !isSelectedImage);
const canUseShikiFileView = isTextFile && !isMarkdown;
const canUseShikiFileView = isTextFile && !isMarkdown && !(isHtml && htmlViewMode === 'preview');
const staticLanguageExtension = React.useMemo(
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
[selectedFilePath],
@@ -1669,6 +1677,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
React.useEffect(() => {
setTextViewMode('edit');
setHtmlViewMode('edit');
}, [selectedFile?.path]);
const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode';
@@ -1699,6 +1708,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return mdViewMode;
}, [mdViewMode]);
const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode';
React.useEffect(() => {
try {
const stored = localStorage.getItem(HTML_VIEWER_MODE_KEY);
if (stored === 'preview') {
setHtmlViewMode('preview');
} else if (stored === 'edit') {
setHtmlViewMode('edit');
}
} catch {
// Ignore localStorage errors
}
}, []);
const saveHtmlViewMode = React.useCallback((mode: 'preview' | 'edit') => {
setHtmlViewMode(mode);
try {
localStorage.setItem(HTML_VIEWER_MODE_KEY, mode);
} catch {
// Ignore localStorage errors
}
}, []);
const getHtmlViewMode = React.useCallback((): 'preview' | 'edit' => {
return htmlViewMode;
}, [htmlViewMode]);
React.useEffect(() => {
if (!pendingFileNavigation || !root) {
return;
@@ -2235,10 +2272,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</>
)}
{isMarkdown && (
{(isMarkdown || isHtmlFile(selectedFile?.path ?? '')) && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
currentMode={isMarkdown ? getMdViewMode() : getHtmlViewMode()}
onToggle={() => {
if (isHtmlFile(selectedFile?.path ?? '')) {
saveHtmlViewMode(getHtmlViewMode() === 'preview' ? 'edit' : 'preview');
} else {
saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview');
}
}}
/>
)}
@@ -2561,6 +2604,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
</ErrorBoundary>
</div>
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
<div className="h-full overflow-hidden">
<iframe
srcDoc={(() => {
// Inject base tag for relative paths (CSS/JS/images) to work
const basePath = selectedFile.path.substring(0, selectedFile.path.lastIndexOf('/') + 1);
if (!basePath) return fileContent;
const baseTag = `<base href="${runtime.isDesktop ? basePath : basePath}">`;
return fileContent.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`);
})()}
className="w-full h-full border-none"
sandbox="allow-scripts allow-same-origin allow-forms"
title="HTML Preview"
/>
</div>
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
renderShikiFileView(selectedFile, draftContent)
) : (