From 620db61f7cde4cff8b6e97f18e214ed06fce7676 Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Tue, 7 Jul 2026 21:01:19 +0800 Subject: [PATCH] chore(web): unify compact truncation into a single ClampedSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact cards previously had two truncation systems taped together: a text-only clamp inside MemoContent (144px, no re-measure on image load, fade tinted to the wrong surface) and a whole-tile cap in ColumnGrid (hard clip mid-image, no affordance), later joined by a content-shape rule choosing an owner per card — which could still stack two Show-more buttons and silently broke footnote navigation in clamped cards. Now there is exactly one mechanism: ClampedSection measures its content and folds anything taller than 420px to a 360px preview with a fade and a Show more/less toggle. MemoBody wraps the whole body in it (reactions stay outside, never hidden); ColumnGrid is pure packing again and never clips; MemoContent is a stateless renderer whose `compact` prop only informs footnote-link behavior; MemoPreview uses a static CSS bound in place of its previously inert toggle. Deltas: text-only compact previews grow from ~6 rows to the same 360px image cards get, and pinned memos clamp like any card (pinned means ordering and a badge, not size). --- web/src/components/ClampedSection.tsx | 73 ++++++++++++++++ web/src/components/ColumnGrid/ColumnGrid.tsx | 48 ++--------- web/src/components/MemoContent/constants.ts | 24 ------ web/src/components/MemoContent/hooks.ts | 36 -------- web/src/components/MemoContent/index.tsx | 40 +-------- web/src/components/MemoContent/types.ts | 3 +- web/src/components/MemoDisplaySettingMenu.tsx | 85 +++++++++---------- .../components/MemoPreview/MemoPreview.tsx | 8 +- .../MemoView/components/MemoBody.tsx | 30 ++++--- .../PagedMemoList/PagedMemoList.tsx | 3 - web/tests/column-grid.test.tsx | 10 --- web/tests/memo-body-compact.test.tsx | 32 +++++-- .../memo-content-compact-threshold.test.ts | 27 ------ 13 files changed, 176 insertions(+), 243 deletions(-) create mode 100644 web/src/components/ClampedSection.tsx delete mode 100644 web/src/components/MemoContent/hooks.ts delete mode 100644 web/tests/memo-content-compact-threshold.test.ts diff --git a/web/src/components/ClampedSection.tsx b/web/src/components/ClampedSection.tsx new file mode 100644 index 000000000..dd5351e06 --- /dev/null +++ b/web/src/components/ClampedSection.tsx @@ -0,0 +1,73 @@ +import { ChevronDown, ChevronUp } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; + +// A collapsed section shows this much content; anything taller folds behind a fade. +export const CLAMP_PREVIEW_HEIGHT_PX = 360; +// Only fold when content is taller than this, so cards barely over the preview aren't +// clamped for the sake of a few hidden pixels. +export const CLAMP_TRIGGER_HEIGHT_PX = 420; + +interface ClampedSectionProps { + /** When false, children render untouched with no measurement. */ + enabled: boolean; + children: ReactNode; +} + +/** + * The one truncation mechanism for compact cards: measure the content, and when it is + * tall enough, collapse it to a fixed-height preview with a fade and a Show more/less + * toggle. The inner div is never clamped, so observing it keeps the measurement live + * while images and embeds load. + */ +const ClampedSection = ({ enabled, children }: ClampedSectionProps) => { + const t = useTranslate(); + const measureRef = useRef(null); + const [clamped, setClamped] = useState(false); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + const el = measureRef.current; + if (!enabled || !el) { + setClamped(false); + return; + } + const check = () => setClamped(el.offsetHeight > CLAMP_TRIGGER_HEIGHT_PX); + check(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, [enabled]); + + const collapsed = clamped && !expanded; + + return ( + <> +
+
+ {children} +
+ {collapsed && ( +
+ )} +
+ {clamped && ( + + )} + + ); +}; + +export default ClampedSection; diff --git a/web/src/components/ColumnGrid/ColumnGrid.tsx b/web/src/components/ColumnGrid/ColumnGrid.tsx index 7c4fa683b..18c4221fa 100644 --- a/web/src/components/ColumnGrid/ColumnGrid.tsx +++ b/web/src/components/ColumnGrid/ColumnGrid.tsx @@ -7,8 +7,6 @@ interface ColumnGridProps { renderItem: (item: T) => ReactNode; /** Optional node packed as the very first tile (e.g. the note composer). */ leading?: ReactNode; - /** When set, cap each tile to this height (px) with a bottom fade, so no tile dominates a column. */ - maxItemHeight?: number; /** Key that must land at the top of column one (e.g. a just-created memo), not the shortest column. */ priorityKey?: string; /** Upper bound on the column count; 0 or undefined means as many as fit. */ @@ -47,7 +45,7 @@ const shortestColumn = (heights: number[]): number => { * memo (or a card growing when its image loads) only shifts the one affected column — * never the whole wall. Balance holds because every new card fills the shortest column. */ -function ColumnGrid({ items, getKey, renderItem, leading, maxItemHeight, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps) { +function ColumnGrid({ items, getKey, renderItem, leading, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps) { const containerRef = useRef(null); const itemRefs = useRef>(new Map()); const refCallbacks = useRef void>>(new Map()); @@ -88,44 +86,24 @@ function ColumnGrid({ items, getKey, renderItem, leading, maxItemHeight, prio if (el) ordered.push({ key, el }); } - // Pass 1 (writes): fix each card's width, drop its own bottom margin so spacing is owned - // entirely by `gap`, and clear any prior cap so the natural height can be measured. - // `firstElementChild` is the item's rendered root (the memo card, or the comment wrapper). + // Pass 1 (writes): fix each card's width and drop its own bottom margin so spacing is + // owned entirely by `gap`. `firstElementChild` is the item's rendered root. Items are + // expected to bound their own height — the grid never clips content itself. for (const { el } of ordered) { el.style.width = `${columnWidth}px`; const child = el.firstElementChild; if (child instanceof HTMLElement) { child.style.marginBottom = "0px"; - child.style.maxHeight = ""; - child.style.overflow = ""; } } - // Pass 2 (reads): natural height of every card, measured once. We read the inner element, - // not the absolutely-positioned wrapper (whose block-formatting context would fold in margins). - const naturalByKey = new Map(); - for (const { key, el } of ordered) { - const child = el.firstElementChild; - naturalByKey.set(key, child instanceof HTMLElement ? child.offsetHeight : el.offsetHeight); - } - - // Pass 3 (writes): clip tall cards to maxItemHeight and show their fade, so a single long - // memo can't dominate a column and the wall stays tidy. The leading tile is never capped. + // Pass 2 (reads): height of every card, measured once after the width writes so the + // browser reflows a single time. We read the inner element, not the absolutely-positioned + // wrapper (whose block-formatting context would fold in margins). const heightByKey = new Map(); for (const { key, el } of ordered) { - const natural = naturalByKey.get(key) ?? 0; - const capped = maxItemHeight != null && key !== LEADING_KEY && natural > maxItemHeight; const child = el.firstElementChild; - if (capped && child instanceof HTMLElement) { - child.style.maxHeight = `${maxItemHeight}px`; - child.style.overflow = "hidden"; - } - // The fade overlay, when present, is the wrapper's last child (rendered right after the item). - const fade = el.lastElementChild; - if (fade instanceof HTMLElement && fade.hasAttribute("data-grid-fade")) { - fade.style.display = capped ? "block" : ""; - } - heightByKey.set(key, capped ? (maxItemHeight as number) : natural); + heightByKey.set(key, child instanceof HTMLElement ? child.offsetHeight : el.offsetHeight); } const heightOf = (key: string) => heightByKey.get(key) ?? 0; @@ -169,7 +147,7 @@ function ColumnGrid({ items, getKey, renderItem, leading, maxItemHeight, prio } setContainerHeight(Math.max(0, ...columnY.map((h) => h - GRID_GAP))); - }, [items, getKey, maxItemHeight, priorityKey, maxColumns, maxColumnWidth]); + }, [items, getKey, priorityKey, maxColumns, maxColumnWidth]); // Keep a stable reference so observer callbacks always run the latest layout. const relayoutRef = useRef(relayout); @@ -256,14 +234,6 @@ function ColumnGrid({ items, getKey, renderItem, leading, maxItemHeight, prio return (
{renderItem(item)} - {maxItemHeight != null && ( -
- )}
); })} diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index 034a25442..b91efd8e1 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -4,30 +4,6 @@ import { defaultSchema } from "rehype-sanitize"; export const TASK_LIST_CLASS = "contains-task-list"; export const TASK_LIST_ITEM_CLASS = "task-list-item"; -// Content rows use leading-6 (1.5rem line-height = 24px at the 16px root font). -const ROW_HEIGHT_PX = 24; - -// Compact mode display settings -export const COMPACT_MODE_CONFIG = { - previewRows: 6, // collapsed preview height, in content rows - triggerRows: 8, // only fold when content is taller than this (2-row buffer over the preview) - gradientHeight: "h-12", // Tailwind class for the bottom fade overlay (~2 rows) -} as const; - -// Height the collapsed preview is clamped to, in pixels. -export const getPreviewMaxHeightPx = () => COMPACT_MODE_CONFIG.previewRows * ROW_HEIGHT_PX; - -// Height a memo must exceed before it gets folded at all, in pixels. -export const getCompactTriggerHeightPx = () => COMPACT_MODE_CONFIG.triggerRows * ROW_HEIGHT_PX; - -// Whether content of the given rendered height should be collapsed. Kept pure for unit testing. -export const shouldCompactContent = (contentHeightPx: number, triggerHeightPx: number) => contentHeightPx > triggerHeightPx; - -export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = { - ALL: { textKey: "memo.show-more", next: "SNIPPET" }, - SNIPPET: { textKey: "memo.show-less", next: "ALL" }, -}; - const TRUSTED_IFRAME_SRC_PATTERNS = [ /^https:\/\/www\.youtube\.com\/embed\/[^?#]+(?:\?.*)?$/i, /^https:\/\/www\.youtube-nocookie\.com\/embed\/[^?#]+(?:\?.*)?$/i, diff --git a/web/src/components/MemoContent/hooks.ts b/web/src/components/MemoContent/hooks.ts deleted file mode 100644 index bd88ece40..000000000 --- a/web/src/components/MemoContent/hooks.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { COMPACT_STATES, getCompactTriggerHeightPx, shouldCompactContent } from "./constants"; -import type { ContentCompactView } from "./types"; - -export const useCompactMode = (enabled: boolean, revision: string) => { - const containerRef = useRef(null); - const [mode, setMode] = useState(undefined); - - useEffect(() => { - if (!enabled || !containerRef.current) { - setMode(undefined); - return; - } - - const contentHeight = Math.max(containerRef.current.scrollHeight, containerRef.current.getBoundingClientRect().height); - const shouldCompact = shouldCompactContent(contentHeight, getCompactTriggerHeightPx()); - setMode((currentMode) => { - if (!shouldCompact) { - return undefined; - } - return currentMode ?? "ALL"; - }); - }, [enabled, revision]); - - const toggle = useCallback(() => { - if (!mode) return; - setMode(COMPACT_STATES[mode].next); - }, [mode]); - - return { containerRef, mode, toggle }; -}; - -export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => { - if (!mode) return ""; - return t(COMPACT_STATES[mode].textKey); -}; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 2ba29a4c2..db828d65d 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -1,31 +1,22 @@ -import { ChevronDown, ChevronUp } from "lucide-react"; import { memo, useMemo } from "react"; import { cn } from "@/lib/utils"; -import { useTranslate } from "@/utils/i18n"; import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention"; -import { COMPACT_MODE_CONFIG, getPreviewMaxHeightPx } from "./constants"; -import { useCompactLabel, useCompactMode } from "./hooks"; import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer"; import { useResolvedMentionUsernames } from "./MentionResolutionContext"; import type { MemoContentProps } from "./types"; +// Stateless markdown renderer. Truncation is not this component's concern — compact cards +// are bounded by ClampedSection around the whole memo body; `compact` here only informs +// the renderer (e.g. footnote links navigate to the detail page instead of scrolling, +// since a collapsed card may hide the target). const MemoContent = (props: MemoContentProps) => { const { className, contentClassName, content, onClick, onDoubleClick } = props; - const t = useTranslate(); - const { - containerRef: memoContentContainerRef, - mode: showCompactMode, - toggle: toggleCompactMode, - } = useCompactMode(Boolean(props.compact), content); const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]); const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames); - const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string); - return (
{ // GitHub renders footnote ref/backref links without an underline (underline on hover only). "[&_[data-footnote-ref]]:no-underline [&_[data-footnote-ref]:hover]:underline", "[&_.data-footnote-backref]:no-underline [&_.data-footnote-backref:hover]:underline", - showCompactMode === "ALL" && "overflow-hidden", contentClassName, )} - style={showCompactMode === "ALL" ? { maxHeight: `${getPreviewMaxHeightPx()}px` } : undefined} onMouseUp={onClick} onDoubleClick={onDoubleClick} > @@ -52,28 +41,7 @@ const MemoContent = (props: MemoContentProps) => { memoName={props.memoName} compact={Boolean(props.compact)} /> - {showCompactMode === "ALL" && ( -
- )}
- {showCompactMode !== undefined && ( -
- -
- )}
); }; diff --git a/web/src/components/MemoContent/types.ts b/web/src/components/MemoContent/types.ts index 9def47b15..05ac21526 100644 --- a/web/src/components/MemoContent/types.ts +++ b/web/src/components/MemoContent/types.ts @@ -4,11 +4,10 @@ export interface MemoContentProps { content: string; /** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */ memoName?: string; + /** The card renders collapsed (ClampedSection), so footnote links navigate instead of scrolling. */ compact?: boolean; className?: string; contentClassName?: string; onClick?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void; } - -export type ContentCompactView = "ALL" | "SNIPPET"; diff --git a/web/src/components/MemoDisplaySettingMenu.tsx b/web/src/components/MemoDisplaySettingMenu.tsx index c1250ec03..27562a827 100644 --- a/web/src/components/MemoDisplaySettingMenu.tsx +++ b/web/src/components/MemoDisplaySettingMenu.tsx @@ -35,19 +35,59 @@ function MemoDisplaySettingMenu({ className }: Props) { setLinkPreview, setMaxColumns, } = useView(); - const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time" || compactMode || !linkPreview || maxColumns !== 1; // Multi-column grids always render compact tiles, so the toggle is shown as on and locked // there; it only becomes a real choice at a single column. const compactLocked = maxColumns !== 1; return ( - +
+ {t("memo.shown-time")} + +
+
+ {t("memo.order")} + +
+
+ + {t("memo.compact-mode")} + + +
+
+ {t("memo.link-preview")} + +
+
{t("memo.layout")} {/* A quiet muted track (28px tall, borderless); only the active option carries the accent fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */} @@ -102,47 +142,6 @@ function MemoDisplaySettingMenu({ className }: Props) { })}
-
- {t("memo.shown-time")} - -
-
- {t("memo.order")} - -
-
- - {t("memo.compact-mode")} - - -
-
- {t("memo.link-preview")} - -
diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx index 53a4549b0..03b1e2ce4 100644 --- a/web/src/components/MemoPreview/MemoPreview.tsx +++ b/web/src/components/MemoPreview/MemoPreview.tsx @@ -128,7 +128,13 @@ const MemoPreview = ({
No content
) ) : ( - hasContent && + // Previews are inert (pointer-events-none), so a static CSS bound replaces the + // interactive clamp a full memo card gets. + hasContent && ( +
+ +
+ ) ); return ( diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index cbed5504a..5a703f045 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -1,6 +1,7 @@ +import ClampedSection from "@/components/ClampedSection"; import { AttachmentListView, LocationDisplayView, RelationListView } from "@/components/MemoMetadata"; +import { isReferenceRelation } from "@/components/MemoMetadata/Relation/relationHelpers"; import { cn } from "@/lib/utils"; -import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemoContent from "../../MemoContent"; import { MemoReactionListView } from "../../MemoReactionListView"; @@ -24,7 +25,7 @@ const MemoBody: React.FC = ({ compact }) => { const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview }); - const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); + const referencedMemos = memo.relations.filter(isReferenceRelation); return ( <> @@ -34,17 +35,20 @@ const MemoBody: React.FC = ({ compact }) => { blurred && !showBlurredContent && "blur-lg transition-all duration-200", )} > - - - - {memo.location && } + {/* Compact bounds the whole body — attachments included — behind one Show more. + Reactions stay outside so they never hide under the fade. */} + + + + + {memo.location && } +
diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index df5d43d99..ccd69d23c 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -30,8 +30,6 @@ export const getMemoKey = (memo: Memo) => `${memo.name}-${memo.updateTime}`; // Columns never stretch past this, so 2 columns on a wide monitor stay readable and the // grid centers in the leftover space instead of filling it. const MAX_COLUMN_WIDTH = 420; -// Grid tiles are clipped to this height (with a fade), so one long memo can't dominate a column. -const MAX_ITEM_HEIGHT = 360; // The grid packs cards into columns, so a card-shaped skeleton doesn't fit; use a spinner. const GridLoader = () => ( @@ -271,7 +269,6 @@ const PagedMemoList = (props: Props) => { getKey={getMemoKey} renderItem={(memo) => props.renderer(memo, { compact: effectiveCompact })} leading={gridLeading} - maxItemHeight={MAX_ITEM_HEIGHT} priorityKey={priorityKey} maxColumns={maxColumns} maxColumnWidth={MAX_COLUMN_WIDTH} diff --git a/web/tests/column-grid.test.tsx b/web/tests/column-grid.test.tsx index b8d46ab61..629ee04f3 100644 --- a/web/tests/column-grid.test.tsx +++ b/web/tests/column-grid.test.tsx @@ -36,16 +36,6 @@ describe("", () => { expect(grid.children[0].querySelector('[data-testid="composer"]')).not.toBeNull(); }); - it("renders a fade overlay per item only when a max item height is set", () => { - const withCap = render( -
} maxItemHeight={360} />, - ); - expect(withCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(2); - - const noCap = render(
} />); - expect(noCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(0); - }); - it("renders nothing for an empty list", () => { const { container } = render(
} />); diff --git a/web/tests/memo-body-compact.test.tsx b/web/tests/memo-body-compact.test.tsx index 5d885cd67..bd5d0df49 100644 --- a/web/tests/memo-body-compact.test.tsx +++ b/web/tests/memo-body-compact.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CLAMP_PREVIEW_HEIGHT_PX, CLAMP_TRIGGER_HEIGHT_PX } from "@/components/ClampedSection"; import MemoBody from "@/components/MemoView/components/MemoBody"; const mockState = vi.hoisted(() => ({ @@ -62,14 +63,18 @@ const createMemo = (content: string) => ({ reactions: [], }); -describe(" compact content", () => { - it("keeps expanded compact content expanded when memo content changes", async () => { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function () { - if ((this as HTMLElement).hasAttribute("data-memo-content")) { - return { x: 0, y: 0, width: 320, height: 1000, top: 0, right: 320, bottom: 1000, left: 0, toJSON: () => ({}) }; - } - return { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}) }; - }); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(" compact body clamp", () => { + it("keeps a buffer between the preview height and the fold trigger", () => { + expect(CLAMP_TRIGGER_HEIGHT_PX).toBeGreaterThan(CLAMP_PREVIEW_HEIGHT_PX); + }); + + it("folds tall compact bodies and keeps them expanded across content changes", async () => { + // jsdom has no layout; report every element as taller than the fold trigger. + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(CLAMP_TRIGGER_HEIGHT_PX + 100); mockState.memo = createMemo("line 1\nline 2"); const { rerender } = render(); @@ -83,4 +88,13 @@ describe(" compact content", () => { expect(screen.getByRole("button", { name: /memo\.show-less/ })).toBeInTheDocument(); }); + + it("renders no clamp when compact is off", () => { + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(CLAMP_TRIGGER_HEIGHT_PX + 100); + + mockState.memo = createMemo("tall content"); + render(); + + expect(screen.queryByRole("button", { name: /memo\.show-more/ })).toBeNull(); + }); }); diff --git a/web/tests/memo-content-compact-threshold.test.ts b/web/tests/memo-content-compact-threshold.test.ts deleted file mode 100644 index 98c8a6d31..000000000 --- a/web/tests/memo-content-compact-threshold.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - COMPACT_MODE_CONFIG, - getCompactTriggerHeightPx, - getPreviewMaxHeightPx, - shouldCompactContent, -} from "@/components/MemoContent/constants"; - -describe("compact folded preview sizing", () => { - it("clamps the collapsed preview to fewer rows than the fold trigger", () => { - // Preview must be shorter than the trigger, otherwise folding shows everything. - expect(COMPACT_MODE_CONFIG.previewRows).toBeLessThan(COMPACT_MODE_CONFIG.triggerRows); - expect(getPreviewMaxHeightPx()).toBeLessThan(getCompactTriggerHeightPx()); - }); - - it("folds only when content is taller than the trigger height", () => { - const trigger = getCompactTriggerHeightPx(); - expect(shouldCompactContent(trigger + 1, trigger)).toBe(true); - }); - - it("leaves content at or below the trigger height fully expanded", () => { - const trigger = getCompactTriggerHeightPx(); - // A memo a row or two over the preview but within the buffer stays open. - expect(shouldCompactContent(getPreviewMaxHeightPx() + 1, trigger)).toBe(false); - expect(shouldCompactContent(trigger, trigger)).toBe(false); - }); -});