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>
  );
}