html-document-view.tsx15.8 KBView on GitHub 'use client';
import { BarChart3, Check, Download, Maximize2, Minimize2, Pencil, Share2, X } from 'lucide-react';
import { ShareTrigger } from '@/modules/sharing/components/ShareTrigger';
import { HtmlAnalyticsDialog } from '@/components/html-analytics-dialog';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
interface HtmlDocumentViewProps {
/** Document id — required to enable editing. Null disables Edit/Save. */
documentId: string | null;
/** Raw, self-contained HTML string. */
content: string;
/** Display title — used for the download filename and the iframe title. */
title: string;
/** True while the document is still loading and `content` is empty. */
isLoading?: boolean;
/** Called after a successful save so the caller can refetch the document. */
onSaved?: () => void;
/**
* When false (default) the non-fullscreen view is constrained to a reading
* column; when true it spans the full available width. Mirrors the parent's
* "Full width" toggle.
*/
fullWidth?: boolean;
}
/** How long the fullscreen command bar stays visible before fading out. */
const FADE_DELAY_MS = 3000;
// Number of consecutive growth measurements that flags a feedback loop. When an
// HTML doc sizes itself with viewport-relative units (e.g. `100vh` slides),
// growing the iframe makes the content report taller, which grows the iframe
// again — an unbounded loop that pins the height to "infinite" and reflows every
// frame (the freeze/lag). Static content reaches a fixed point immediately and
// never trips this.
const MAX_GROWTH_TICKS = 8;
// Hard ceiling for the auto-sized (non-fullscreen) iframe, as a fraction of the
// viewport. Content taller than this scrolls inside the iframe instead of
// stretching the page. Also guarantees the auto-size loop always settles.
const MAX_HEIGHT_VH = 0.85;
const FALLBACK_MAX_HEIGHT_PX = 900;
const maxEmbedHeight = () =>
typeof window !== 'undefined'
? Math.round(window.innerHeight * MAX_HEIGHT_VH)
: FALLBACK_MAX_HEIGHT_PX;
// `allow-same-origin` is required: the parent reads `iframe.contentDocument`
// to drive in-place editing, auto-sizing, and the keydown blocker. Without it
// the document is an opaque origin and all of that silently no-ops.
const SANDBOX = 'allow-scripts allow-same-origin allow-downloads allow-forms allow-popups';
/**
* Self-contained renderer for `html` documents. Owns everything intrinsic to
* the HTML doc type: the sandboxed iframe, in-place WYSIWYG editing, auto-sizing
* to content, fullscreen, and download. Use it anywhere an html document is
* shown so the behavior stays consistent.
*/
export function HtmlDocumentView({
documentId,
content,
title,
isLoading,
onSaved,
fullWidth = false,
}: HtmlDocumentViewProps) {
const trpc = useTRPC();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [editing, setEditing] = useState(false);
const [analyticsOpen, setAnalyticsOpen] = useState(false);
// Bumped on cancel to force-remount the iframe and discard unsaved edits.
const [editKey, setEditKey] = useState(0);
// The HTML actually fed to the iframe. Held separately from `content` so a
// save round-trip doesn't reload the iframe (see the sync effect below).
const [iframeSrc, setIframeSrc] = useState(content);
const lastSavedContent = useRef<string | null>(null);
const [contentHeight, setContentHeight] = useState<number | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const resizeObserver = useRef<ResizeObserver | null>(null);
const growthTicks = useRef(0);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Always points at the latest revealControls so the iframe's mousemove listener
// (attached once on load) calls the current closure without re-binding.
const revealControlsRef = useRef<() => void>(() => {});
const { mutate: updateHtmlContent, isPending: saving } = useMutation({
...trpc.documents.updateHtmlContent.mutationOptions(),
onSuccess: () => {
setEditing(false);
onSaved?.();
},
onError: (err) => toast.error(`Failed to save document: ${err.message}`),
});
// ── In-place WYSIWYG editing ─────────────────────────────────────────
const startEditing = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
doc.designMode = 'on';
setEditing(true);
}, []);
const cancelEditing = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (doc) doc.designMode = 'off';
setEditing(false);
setEditKey((k) => k + 1);
}, []);
const saveEditing = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!documentId || !doc) return;
doc.designMode = 'off';
const serialized = `<!DOCTYPE html>${doc.documentElement.outerHTML}`;
// Remember what we saved so the echoed-back content doesn't reload the
// iframe — it already displays exactly this.
lastSavedContent.current = serialized;
updateHtmlContent({ documentId, content: serialized });
}, [documentId, updateHtmlContent]);
// Feed external content changes into the iframe — but skip the change caused
// by our own save: the iframe already shows it, and a srcDoc swap would
// reload the page and make a deck flicker / re-initialize.
useEffect(() => {
if (content === lastSavedContent.current) {
lastSavedContent.current = null;
return;
}
setIframeSrc(content);
}, [content]);
// While editing, swallow keydowns inside the iframe before they reach the
// document's own handlers — otherwise a deck's space / arrow-key slide
// navigation hijacks the keys the user is trying to type with. Capture phase
// + stopImmediatePropagation beats the deck's bubble-phase listener; the
// native editing default action (typing, caret motion) still runs.
useEffect(() => {
if (!editing) return;
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
const block = (e: KeyboardEvent) => e.stopImmediatePropagation();
doc.addEventListener('keydown', block, true);
return () => doc.removeEventListener('keydown', block, true);
}, [editing]);
// ── Auto-size the iframe to its content ──────────────────────────────
const measureHeight = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
// Clamp to a hard ceiling: taller docs scroll inside the iframe rather than
// stretching the page, and the auto-size loop can never run away.
const next = Math.min(doc.documentElement.scrollHeight, maxEmbedHeight());
if (next <= 0) return;
setContentHeight((prev) => {
// Threshold avoids a measure→resize→measure feedback loop on settled content.
if (prev != null && Math.abs(prev - next) < 4) {
growthTicks.current = 0;
return prev;
}
// A run of monotonic growth means the content height tracks the iframe
// height (viewport-relative sizing) and will never settle. Freeze at the
// last value and stop observing so the doc renders bounded instead of
// growing forever and reflowing every frame.
if (prev != null && next > prev) {
growthTicks.current += 1;
if (growthTicks.current >= MAX_GROWTH_TICKS) {
resizeObserver.current?.disconnect();
return prev;
}
} else {
growthTicks.current = 0;
}
return next;
});
}, []);
const handleIframeLoad = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
growthTicks.current = 0;
measureHeight();
resizeObserver.current?.disconnect();
const observer = new ResizeObserver(() => measureHeight());
observer.observe(doc.documentElement);
resizeObserver.current = observer;
// In fullscreen the iframe covers the whole screen, so mouse movement never
// reaches the wrapper's onMouseMove and the faded command bar can't be
// summoned back. Listen inside the (same-origin) document too. The listener
// dies with the document on the next load, so no explicit teardown needed.
doc.addEventListener('mousemove', () => revealControlsRef.current());
// Escape always exits fullscreen, even when focus is inside the iframe (e.g.
// after clicking into a deck to use its arrow-key navigation).
doc.addEventListener('keydown', (e) => {
if (e.key === 'Escape') setFullscreen(false);
});
}, [measureHeight]);
useEffect(() => () => resizeObserver.current?.disconnect(), []);
// ── Fullscreen command-bar fade ──────────────────────────────────────
const scheduleFade = useCallback(() => {
if (fadeTimer.current) clearTimeout(fadeTimer.current);
fadeTimer.current = setTimeout(() => setControlsVisible(false), FADE_DELAY_MS);
}, []);
useEffect(() => {
if (fadeTimer.current) clearTimeout(fadeTimer.current);
// The bar only fades in fullscreen — and never while editing, where the
// Save / Cancel actions must stay reachable.
if (!fullscreen || editing) {
setControlsVisible(true);
return;
}
setControlsVisible(true);
scheduleFade();
return () => {
if (fadeTimer.current) clearTimeout(fadeTimer.current);
};
}, [fullscreen, editing, scheduleFade]);
const revealControls = useCallback(() => {
if (!fullscreen || editing) return;
setControlsVisible(true);
scheduleFade();
}, [fullscreen, editing, scheduleFade]);
useEffect(() => {
revealControlsRef.current = revealControls;
}, [revealControls]);
// Escape exits fullscreen when focus is on the page (the iframe handles the
// in-iframe case via its own keydown listener in handleIframeLoad).
useEffect(() => {
if (!fullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFullscreen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [fullscreen]);
if (isLoading && !content) {
return <p className="text-sm text-muted-foreground italic">Loading…</p>;
}
if (!content) {
return <p className="text-sm text-muted-foreground italic">No HTML content yet.</p>;
}
const downloadHtml = () => {
const blobUrl = URL.createObjectURL(new Blob([content], { type: 'text/html' }));
const link = document.createElement('a');
link.href = blobUrl;
link.download = `${(title || 'presentation').replace(/[^a-z0-9-]+/gi, '-')}.html`;
link.click();
URL.revokeObjectURL(blobUrl);
};
const btn = 'inline-flex items-center gap-2 text-sm transition-colors disabled:opacity-50';
return (
<div
onMouseMove={revealControls}
className={cn(
'flex flex-col gap-3',
fullscreen
? 'fixed inset-0 z-50 bg-background'
: cn('mx-auto w-full py-3', !fullWidth && 'max-w-[100ch]'),
)}
>
{/* Command bar — in flow normally, floating + fading in fullscreen */}
<div
className={cn(
'flex items-center gap-4',
!editing && 'justify-between',
fullscreen &&
'absolute left-4 right-4 top-4 z-10 rounded-lg border border-border bg-background/90 px-3 py-2 backdrop-blur transition-opacity duration-300',
fullscreen && !controlsVisible && 'pointer-events-none opacity-0',
)}
>
{editing ? (
<>
<button
type="button"
onClick={saveEditing}
disabled={saving}
className={cn(btn, 'text-foreground hover:text-primary')}
>
<Check className="size-3.5" />
{saving ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={cancelEditing}
disabled={saving}
className={cn(btn, 'text-muted-foreground hover:text-foreground')}
>
<X className="size-3.5" />
Cancel
</button>
<span className="text-xs text-muted-foreground">
Click any text in the page to edit it.
</span>
</>
) : (
<>
<div className="flex items-center gap-4">
{documentId && (
<button
type="button"
onClick={startEditing}
className={cn(btn, 'text-muted-foreground hover:text-foreground')}
>
<Pencil className="size-3.5" />
Edit
</button>
)}
{documentId && (
/* One share control, wherever sharing is reachable from. This used to
be a Dialog that took the screen to mint ONE anonymous, eternal
public link, reported every failure through a toast, and had no
concept of a person, a role or an audience. The trigger keeps this
toolbar's own styling — a trigger belongs to the surface it sits on
— and the panel is the same one every other document opens. */
<ShareTrigger documentId={documentId}>
<button
type="button"
className={cn(
btn,
'text-muted-foreground hover:text-foreground cursor-pointer',
)}
>
<Share2 className="size-3.5" />
Share
</button>
</ShareTrigger>
)}
<button
type="button"
onClick={downloadHtml}
className={cn(btn, 'text-muted-foreground hover:text-foreground')}
>
<Download className="size-3.5" />
Download HTML
</button>
<button
type="button"
onClick={() => setFullscreen((f) => !f)}
className={cn(btn, 'text-muted-foreground hover:text-foreground')}
>
{fullscreen ? (
<Minimize2 className="size-3.5" />
) : (
<Maximize2 className="size-3.5" />
)}
{fullscreen ? 'Exit full screen' : 'Full screen'}
</button>
</div>
{documentId && (
<button
type="button"
onClick={() => setAnalyticsOpen(true)}
className={cn(btn, 'text-muted-foreground hover:text-foreground')}
>
<BarChart3 className="size-3.5" />
Analytics
</button>
)}
</>
)}
</div>
{!fullscreen && !editing && (
<p className="text-xs text-muted-foreground">
Presentation formatting is more accurate when in full-screen.
</p>
)}
<iframe
key=[redacted]
ref={iframeRef}
srcDoc={iframeSrc || content}
onLoad={handleIframeLoad}
sandbox={SANDBOX}
style={!fullscreen && contentHeight ? { height: `${contentHeight}px` } : undefined}
className={cn(
'w-full bg-background',
fullscreen
? 'min-h-0 flex-1'
: cn('min-h-[80vh] rounded-lg border', editing ? 'border-primary' : 'border-border'),
)}
title={title || 'HTML presentation'}
/>
{documentId && (
<HtmlAnalyticsDialog
documentId={documentId}
open={analyticsOpen}
onOpenChange={setAnalyticsOpen}
/>
)}
</div>
);
}