1"use client"; 2 3/** 4 * Expandable — cap a long block's VERTICAL height, scroll inside the cap, and 5 * offer an Expand/Collapse control. 6 * 7 * Motivation (user request, third of the editor pass): "when I paste long 8 * text the editor is as long as the whole text" — pasted content should wrap, 9 * and when it's long it should be *capped and expandable* rather than 10 * stretching the page. 11 * 12 * Contract: 13 * - Short content renders EXACTLY as the child does (no cap, no scroll, no 14 * button) — the cap is only visible when the content genuinely exceeds 15 * it, so the common case is unchanged. 16 * - "Long" is decided by MEASUREMENT (see useMeasuredHeight), so the same 17 * component works for wrapping text and for fixed-metric code without the 18 * caller knowing the content shape. 19 * - When capped: the content is clipped to `cap` and scrolls vertically; 20 * Expand switches to full height; Collapse returns to the cap. 21 * 22 * The cap is applied via inline `max-height` (NOT a Tailwind arbitrary class): 23 * the value is a runtime number and the JIT compiler only emits class strings 24 * it can see statically. 25 * 26 * The expand/collapse choice is per-session view state — deliberately not 27 * persisted to block properties, so the stored block JSON stays clean. 28 */ 29import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; 30 31/** 32 * Measure an element's natural (uncapped) height, re-measuring on resize. 33 * 34 * `measure` runs synchronously before paint (useLayoutEffect), so the consumer 35 * can apply the cap on the SAME commit that first renders the content — no 36 * flash of un-capped content. A ResizeObserver covers window resize, 37 * column-width changes and font loading (wrapped-text height changes when font 38 * metrics settle). 39 */ 40export function useMeasuredHeight(ref: React.RefObject<HTMLElement | null>) { 41 const [height, setHeight] = useState(0); 42 43 useLayoutEffect(() => { 44 const el = ref.current; 45 if (!el) return; 46 const measure = () => setHeight(el.scrollHeight); 47 measure(); 48 // ResizeObserver is universal in modern browsers but absent in some 49 // environments (jsdom tests, very old engines); degrade to the initial 50 // measurement rather than throw. 51 if (typeof ResizeObserver === "undefined") return; 52 const ro = new ResizeObserver(measure); 53 ro.observe(el); 54 return () => ro.disconnect(); 55 }, [ref]); 56 57 return height; 58} 59 60export interface ExpandableProps { 61 children: ReactNode; 62 /** Cap height (px). A multiple of the content's line height. */ 63 cap: number; 64 /** ARIA label target, e.g. "code block" or "text block". */ 65 label: string; 66 /** Exact line count for the button label (code); omit for prose. */ 67 lines?: number; 68 /** 69 * Force the cap OFF (render the child bare, full height). Used by inline 70 * text blocks while they are the ACTIVE editing target — a live Tiptap 71 * caret must never be clipped below the cap's fold. 72 */ 73 liftCap?: boolean; 74 /** Extra classes for the capping box (e.g. the code block's rounded/border). */ 75 className?: string; 76} 77 78export function Expandable({ children, cap, label, lines, liftCap, className }: ExpandableProps) { 79 const contentRef = useRef<HTMLDivElement>(null); 80 const [expanded, setExpanded] = useState(false); 81 const height = useMeasuredHeight(contentRef); 82 83 // The content is "long" when it exceeds the cap (height===0 is the 84 // pre-measure tick, so short content is never flagged). A lifted cap 85 // (liftCap — e.g. an inline text block that is the active editing target) 86 // suppresses BOTH the cap and the button. 87 const isLong = !liftCap && height > cap; 88 // Actually clipped: long AND not expanded. 89 const capped = isLong && !expanded; 90 91 // If the content shrinks to fit (edits, window resize), drop the expanded 92 // state so the button doesn't sit next to a short block. 93 useEffect(() => { 94 if (expanded && !isLong) setExpanded(false); 95 }, [expanded, isLong]); 96 97 return ( 98 <div> 99 <div 100 ref={contentRef} 101 data-testid="expandable-content" 102 style={capped ? { maxHeight: cap, overflowY: "auto" } : undefined} 103 className={className} 104 > 105 {children} 106 </div> 107 {isLong ? ( 108 <div className="flex items-center justify-center py-0.5"> 109 <button 110 type="button" 111 aria-expanded={expanded} 112 aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`} 113 data-testid="expandable-toggle" 114 className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" 115 onMouseDown={(e) => e.preventDefault()} // never steal focus from the editor 116 onClick={() => setExpanded((v) => !v)} 117 > 118 {expanded ? "Collapse" : lines != null ? `Expand · ${lines} lines` : "Expand"} 119 </button> 120 </div> 121 ) : null} 122 </div> 123 ); 124} 125