"use client"; /** * Expandable — cap a long block's VERTICAL height, scroll inside the cap, and * offer an Expand/Collapse control. * * Motivation (user request, third of the editor pass): "when I paste long * text the editor is as long as the whole text" — pasted content should wrap, * and when it's long it should be *capped and expandable* rather than * stretching the page. * * Contract: * - Short content renders EXACTLY as the child does (no cap, no scroll, no * button) — the cap is only visible when the content genuinely exceeds * it, so the common case is unchanged. * - "Long" is decided by MEASUREMENT (see useMeasuredHeight), so the same * component works for wrapping text and for fixed-metric code without the * caller knowing the content shape. * - When capped: the content is clipped to `cap` and scrolls vertically; * Expand switches to full height; Collapse returns to the cap. * * The cap is applied via inline `max-height` (NOT a Tailwind arbitrary class): * the value is a runtime number and the JIT compiler only emits class strings * it can see statically. * * The expand/collapse choice is per-session view state — deliberately not * persisted to block properties, so the stored block JSON stays clean. */ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; /** * Measure an element's natural (uncapped) height, re-measuring on resize. * * `measure` runs synchronously before paint (useLayoutEffect), so the consumer * can apply the cap on the SAME commit that first renders the content — no * flash of un-capped content. A ResizeObserver covers window resize, * column-width changes and font loading (wrapped-text height changes when font * metrics settle). */ export function useMeasuredHeight(ref: React.RefObject) { const [height, setHeight] = useState(0); useLayoutEffect(() => { const el = ref.current; if (!el) return; const measure = () => setHeight(el.scrollHeight); measure(); // ResizeObserver is universal in modern browsers but absent in some // environments (jsdom tests, very old engines); degrade to the initial // measurement rather than throw. if (typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [ref]); return height; } export interface ExpandableProps { children: ReactNode; /** Cap height (px). A multiple of the content's line height. */ cap: number; /** ARIA label target, e.g. "code block" or "text block". */ label: string; /** Exact line count for the button label (code); omit for prose. */ lines?: number; /** * Force the cap OFF (render the child bare, full height). Used by inline * text blocks while they are the ACTIVE editing target — a live Tiptap * caret must never be clipped below the cap's fold. */ liftCap?: boolean; /** Extra classes for the capping box (e.g. the code block's rounded/border). */ className?: string; } export function Expandable({ children, cap, label, lines, liftCap, className }: ExpandableProps) { const contentRef = useRef(null); const [expanded, setExpanded] = useState(false); const height = useMeasuredHeight(contentRef); // The content is "long" when it exceeds the cap (height===0 is the // pre-measure tick, so short content is never flagged). A lifted cap // (liftCap — e.g. an inline text block that is the active editing target) // suppresses BOTH the cap and the button. const isLong = !liftCap && height > cap; // Actually clipped: long AND not expanded. const capped = isLong && !expanded; // If the content shrinks to fit (edits, window resize), drop the expanded // state so the button doesn't sit next to a short block. useEffect(() => { if (expanded && !isLong) setExpanded(false); }, [expanded, isLong]); return (
{children}
{isLong ? (
) : null}
); }