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:
committed by
GitHub
parent
f032df2298
commit
744b29c492
@@ -263,6 +263,12 @@ const isMarkdownFile = (path: string): boolean => {
|
|||||||
return ext === 'md' || ext === 'markdown';
|
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 {
|
interface FileRowProps {
|
||||||
node: FileNode;
|
node: FileNode;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -466,6 +472,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||||
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
|
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
|
||||||
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
|
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
|
||||||
|
const [htmlViewMode, setHtmlViewMode] = React.useState<'preview' | 'edit'>('edit');
|
||||||
|
|
||||||
const lightTheme = React.useMemo(
|
const lightTheme = React.useMemo(
|
||||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
() => 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 canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
|
||||||
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
||||||
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
||||||
|
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
||||||
const isTextFile = Boolean(selectedFile && !isSelectedImage);
|
const isTextFile = Boolean(selectedFile && !isSelectedImage);
|
||||||
const canUseShikiFileView = isTextFile && !isMarkdown;
|
const canUseShikiFileView = isTextFile && !isMarkdown && !(isHtml && htmlViewMode === 'preview');
|
||||||
const staticLanguageExtension = React.useMemo(
|
const staticLanguageExtension = React.useMemo(
|
||||||
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
|
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
|
||||||
[selectedFilePath],
|
[selectedFilePath],
|
||||||
@@ -1669,6 +1677,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setTextViewMode('edit');
|
setTextViewMode('edit');
|
||||||
|
setHtmlViewMode('edit');
|
||||||
}, [selectedFile?.path]);
|
}, [selectedFile?.path]);
|
||||||
|
|
||||||
const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode';
|
const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode';
|
||||||
@@ -1699,6 +1708,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return mdViewMode;
|
return mdViewMode;
|
||||||
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!pendingFileNavigation || !root) {
|
if (!pendingFileNavigation || !root) {
|
||||||
return;
|
return;
|
||||||
@@ -2235,10 +2272,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMarkdown && (
|
{(isMarkdown || isHtmlFile(selectedFile?.path ?? '')) && (
|
||||||
<PreviewToggleButton
|
<PreviewToggleButton
|
||||||
currentMode={getMdViewMode()}
|
currentMode={isMarkdown ? getMdViewMode() : getHtmlViewMode()}
|
||||||
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
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>
|
</ErrorBoundary>
|
||||||
</div>
|
</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' ? (
|
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
|
||||||
renderShikiFileView(selectedFile, draftContent)
|
renderShikiFileView(selectedFile, draftContent)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user