From 744b29c4926bd2b24b37ff2d0a770d7b9a0e7d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ng=C3=B4=20Th=C6=B0=E1=BB=A3ng?= <83950837+nguyenngothuong@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:33:17 +0700 Subject: [PATCH] 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 --- .../ui/src/components/views/FilesView.tsx | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index ba3eee29..9b0ae0da 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ mode = 'full' }) => { )} - {isMarkdown && ( + {(isMarkdown || isHtmlFile(selectedFile?.path ?? '')) && ( 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 = ({ mode = 'full' }) => { /> + ) : selectedFile && isHtml && htmlViewMode === 'preview' ? ( +
+