11'use client' ;
22
33/**
4- * In-app document surface for text-like files. Renders three ways:
5- * - Markdown (.md …) → formatted preview (GFM + syntax-highlighted code fences),
6- * with a Preview/Source toggle.
4+ * In-app document surface for text-like files. Renders several ways:
5+ * - Markdown (.md …) → formatted preview (GFM + syntax-highlighted code fences).
6+ * - HTML (.html …) → rendered in a sandboxed <iframe> (isolated, opaque origin — it can
7+ * never reach your session/cookies). Page JavaScript is OFF by default
8+ * and toggled on demand (sandbox="allow-scripts", no allow-same-origin).
79 * - Code / text → syntax-highlighted, read-only.
810 * - Edit mode → a plain monospace editor with Save, when the file is editable.
911 *
10- * Saving is delegated to `onSave` (the Drive page re-uploads server files as a new version,
11- * or re-encrypts and replaces Zero-Knowledge files).
12+ * Renderable files (Markdown / HTML) get a Preview/Source toggle. Saving is delegated to
13+ * `onSave` (the Drive page re-uploads server files as a new version, or re-encrypts and
14+ * replaces Zero-Knowledge files).
1215 */
1316import { useMemo , useState } from 'react' ;
1417import Markdown from 'react-markdown' ;
1518import remarkGfm from 'remark-gfm' ;
1619import rehypeHighlight from 'rehype-highlight' ;
1720import hljs from 'highlight.js' ;
18- import { Eye , Code2 , Pencil , Save , Loader2 } from 'lucide-react' ;
19- import { codeLanguage , isMarkdown } from '@/lib/fileType' ;
21+ import { Eye , Code2 , Pencil , Save , Loader2 , Play } from 'lucide-react' ;
22+ import { codeLanguage , isHtml , isMarkdown } from '@/lib/fileType' ;
2023import { useT } from '@/lib/i18n' ;
2124import { confirm , toast } from '@/components/ui/overlays' ;
2225
@@ -38,9 +41,13 @@ export function TextDocument({
3841} ) {
3942 const { t } = useT ( ) ;
4043 const md = isMarkdown ( name , mime ) ;
44+ const html = isHtml ( name , mime ) ;
45+ const renderable = md || html ;
46+
4147 const [ text , setText ] = useState ( initialText ) ;
4248 const [ editing , setEditing ] = useState ( false ) ;
43- const [ showSource , setShowSource ] = useState ( false ) ;
49+ const [ mode , setMode ] = useState < 'preview' | 'source' > ( renderable ? 'preview' : 'source' ) ;
50+ const [ jsEnabled , setJsEnabled ] = useState ( false ) ;
4451 const [ saving , setSaving ] = useState ( false ) ;
4552 const dirty = text !== initialText ;
4653
@@ -64,27 +71,42 @@ export function TextDocument({
6471 setEditing ( false ) ;
6572 }
6673
74+ const showPreview = renderable && mode === 'preview' && ! editing ;
75+
6776 return (
6877 < div className = "flex h-full w-full max-w-5xl flex-col self-stretch overflow-hidden rounded-lg border border-white/10 bg-ink-900" >
6978 { /* Mini toolbar */ }
7079 < div className = "flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-3 py-2" >
71- < div className = "flex items-center gap-1 " >
72- { md && ! editing && (
80+ < div className = "flex items-center gap-2 " >
81+ { renderable && ! editing && (
7382 < div className = "flex rounded-lg border border-white/10 p-0.5 text-xs" >
7483 < button
75- className = { `flex items-center gap-1 rounded-md px-2 py-1 ${ ! showSource ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200' } ` }
76- onClick = { ( ) => setShowSource ( false ) }
84+ className = { `flex items-center gap-1 rounded-md px-2 py-1 ${ mode === 'preview' ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200' } ` }
85+ onClick = { ( ) => setMode ( 'preview' ) }
7786 >
7887 < Eye size = { 13 } /> { t ( 'viewer.preview' ) }
7988 </ button >
8089 < button
81- className = { `flex items-center gap-1 rounded-md px-2 py-1 ${ showSource ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200' } ` }
82- onClick = { ( ) => setShowSource ( true ) }
90+ className = { `flex items-center gap-1 rounded-md px-2 py-1 ${ mode === 'source' ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200' } ` }
91+ onClick = { ( ) => setMode ( 'source' ) }
8392 >
8493 < Code2 size = { 13 } /> { t ( 'viewer.source' ) }
8594 </ button >
8695 </ div >
8796 ) }
97+ { html && showPreview && (
98+ < button
99+ onClick = { ( ) => setJsEnabled ( ( v ) => ! v ) }
100+ title = { jsEnabled ? t ( 'viewer.jsOnTitle' ) : t ( 'viewer.jsOffTitle' ) }
101+ className = { `flex items-center gap-1 rounded-lg border px-2 py-1 text-xs ${
102+ jsEnabled
103+ ? 'border-amber-400/40 bg-amber-400/10 text-amber-300'
104+ : 'border-white/10 text-zinc-400 hover:text-zinc-200'
105+ } `}
106+ >
107+ < Play size = { 12 } /> { t ( 'viewer.runJs' ) }
108+ </ button >
109+ ) }
88110 </ div >
89111 { editable && onSave && (
90112 < div className = "flex items-center gap-2" >
@@ -116,12 +138,24 @@ export function TextDocument({
116138 spellCheck = { false }
117139 className = "h-full w-full resize-none bg-transparent p-4 font-mono text-xs leading-relaxed text-zinc-200 outline-none"
118140 />
119- ) : md && ! showSource ? (
141+ ) : showPreview && md ? (
120142 < div className = "md-prose p-5" >
121143 < Markdown remarkPlugins = { [ remarkGfm ] } rehypePlugins = { text . length < HIGHLIGHT_LIMIT ? [ rehypeHighlight ] : [ ] } >
122144 { text }
123145 </ Markdown >
124146 </ div >
147+ ) : showPreview && html ? (
148+ < iframe
149+ // Re-mount on JS toggle so the sandbox flags actually change. allow-scripts WITHOUT
150+ // allow-same-origin gives the page an opaque origin: it can run but can't touch the
151+ // app's cookies, storage or session.
152+ key = { jsEnabled ? 'js' : 'nojs' }
153+ title = { name }
154+ srcDoc = { text }
155+ sandbox = { jsEnabled ? 'allow-scripts' : '' }
156+ referrerPolicy = "no-referrer"
157+ className = "h-full w-full border-0 bg-white"
158+ />
125159 ) : (
126160 < CodeBlock name = { name } text = { text } highlight = { ! md } />
127161 ) }
@@ -132,7 +166,7 @@ export function TextDocument({
132166
133167/** Read-only, syntax-highlighted code/text block. */
134168function CodeBlock ( { name, text, highlight } : { name : string ; text : string ; highlight : boolean } ) {
135- const html = useMemo ( ( ) => {
169+ const htmlOut = useMemo ( ( ) => {
136170 if ( ! highlight || text . length > HIGHLIGHT_LIMIT ) return null ;
137171 try {
138172 const lang = codeLanguage ( name ) ;
@@ -145,8 +179,8 @@ function CodeBlock({ name, text, highlight }: { name: string; text: string; high
145179
146180 return (
147181 < pre className = "h-full w-full overflow-auto p-4 text-xs leading-relaxed" >
148- { html ? (
149- < code className = "hljs bg-transparent !p-0" dangerouslySetInnerHTML = { { __html : html } } />
182+ { htmlOut ? (
183+ < code className = "hljs bg-transparent !p-0" dangerouslySetInnerHTML = { { __html : htmlOut } } />
150184 ) : (
151185 < code className = "text-zinc-300" > { text } </ code >
152186 ) }
0 commit comments