chore(web): unify compact truncation into a single ClampedSection

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).
pull/6080/head
johnnyjoygh 4 days ago
parent 9fae524221
commit 620db61f7c

@ -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<HTMLDivElement>(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 (
<>
<div
className={cn("relative w-full", collapsed && "overflow-hidden")}
style={collapsed ? { maxHeight: CLAMP_PREVIEW_HEIGHT_PX } : undefined}
>
<div ref={measureRef} className="w-full flex flex-col justify-start items-start gap-2">
{children}
</div>
{collapsed && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-linear-to-t from-card from-0% via-card/60 via-40% to-transparent to-100%" />
)}
</div>
{clamped && (
<button
type="button"
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded((prev) => !prev)}
>
<span>{t(collapsed ? "memo.show-more" : "memo.show-less")}</span>
{collapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
</button>
)}
</>
);
};
export default ClampedSection;

@ -7,8 +7,6 @@ interface ColumnGridProps<T> {
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<T>({ items, getKey, renderItem, leading, maxItemHeight, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps<T>) {
function ColumnGrid<T>({ items, getKey, renderItem, leading, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const refCallbacks = useRef<Map<string, (el: HTMLDivElement | null) => void>>(new Map());
@ -88,44 +86,24 @@ function ColumnGrid<T>({ 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<string, number>();
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<string, number>();
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<T>({ 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<T>({ items, getKey, renderItem, leading, maxItemHeight, prio
return (
<div key={key} ref={getItemRef(key)} className="absolute top-0 left-0" style={{ willChange: "transform" }}>
{renderItem(item)}
{maxItemHeight != null && (
<div
data-grid-fade
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 hidden h-12 rounded-b-lg"
style={{ background: "linear-gradient(to top, var(--card), transparent)" }}
/>
)}
</div>
);
})}

@ -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,

@ -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<HTMLDivElement>(null);
const [mode, setMode] = useState<ContentCompactView | undefined>(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);
};

@ -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 (
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div
ref={memoContentContainerRef}
data-memo-content
className={cn(
"relative w-full max-w-full wrap-break-word text-base leading-6",
@ -39,10 +30,8 @@ const MemoContent = (props: MemoContentProps) => {
// 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" && (
<div
className={cn(
"absolute inset-x-0 bottom-0 pointer-events-none",
COMPACT_MODE_CONFIG.gradientHeight,
"bg-linear-to-t from-background from-0% via-background/60 via-40% to-transparent to-100%",
)}
/>
)}
</div>
{showCompactMode !== undefined && (
<div className="relative w-full mt-2">
<button
type="button"
className="group inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleCompactMode}
>
<span>{compactLabel}</span>
{showCompactMode === "ALL" ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
</button>
</div>
)}
</div>
);
};

@ -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";

@ -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 (
<Popover>
<PopoverTrigger className={cn(className, isApplying ? "text-primary bg-primary/10 rounded" : "opacity-40")}>
<PopoverTrigger className={cn(className, "opacity-40 hover:opacity-100 transition-opacity")}>
<Settings2Icon className="w-4 h-auto shrink-0" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
<div className="flex flex-col gap-2 p-1">
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.shown-time")}</span>
<Select value={timeBasis} onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="create_time">{t("common.created-at")}</SelectItem>
<SelectItem value="update_time">{t("common.last-updated-at")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.order")}</span>
<Select
value={orderByTimeAsc.toString()}
onValueChange={(value) => {
if ((value === "true") !== orderByTimeAsc) {
toggleSortOrder();
}
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">{t("memo.newest-first")}</SelectItem>
<SelectItem value="true">{t("memo.oldest-first")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className={cn("text-sm shrink-0 mr-3", compactLocked ? "text-muted-foreground" : "text-foreground")}>
{t("memo.compact-mode")}
</span>
<Switch checked={compactLocked || compactMode} onCheckedChange={setCompactMode} disabled={compactLocked} />
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />
</div>
<div className="w-full flex flex-row justify-between items-center border-t border-border/50 pt-2">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.layout")}</span>
{/* 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) {
})}
</div>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.shown-time")}</span>
<Select value={timeBasis} onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="create_time">{t("common.created-at")}</SelectItem>
<SelectItem value="update_time">{t("common.last-updated-at")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.order")}</span>
<Select
value={orderByTimeAsc.toString()}
onValueChange={(value) => {
if ((value === "true") !== orderByTimeAsc) {
toggleSortOrder();
}
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">{t("memo.newest-first")}</SelectItem>
<SelectItem value="true">{t("memo.oldest-first")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className={cn("text-sm shrink-0 mr-3", compactLocked ? "text-muted-foreground" : "text-foreground")}>
{t("memo.compact-mode")}
</span>
<Switch checked={compactLocked || compactMode} onCheckedChange={setCompactMode} disabled={compactLocked} />
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />
</div>
</div>
</PopoverContent>
</Popover>

@ -128,7 +128,13 @@ const MemoPreview = ({
<div className="text-sm text-muted-foreground truncate min-w-0">No content</div>
)
) : (
hasContent && <MemoContent content={content} compact={compact} />
// Previews are inert (pointer-events-none), so a static CSS bound replaces the
// interactive clamp a full memo card gets.
hasContent && (
<div className="max-h-36 w-full overflow-hidden">
<MemoContent content={content} compact={compact} />
</div>
)
);
return (

@ -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<MemoBodyProps> = ({ 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<MemoBodyProps> = ({ compact }) => {
blurred && !showBlurredContent && "blur-lg transition-all duration-200",
)}
>
<MemoContent
key={memo.name}
memoName={memo.name}
content={memo.content}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compact={memo.pinned ? false : compact} // Always show full content when pinned
/>
<AttachmentListView attachments={memo.attachments} onImagePreview={openPreview} />
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
{memo.location && <LocationDisplayView location={memo.location} />}
{/* Compact bounds the whole body attachments included behind one Show more.
Reactions stay outside so they never hide under the fade. */}
<ClampedSection enabled={Boolean(compact)}>
<MemoContent
memoName={memo.name}
content={memo.content}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compact={Boolean(compact)}
/>
<AttachmentListView attachments={memo.attachments} onImagePreview={openPreview} />
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
{memo.location && <LocationDisplayView location={memo.location} />}
</ClampedSection>
<MemoReactionListView memo={memo} reactions={memo.reactions} />
</div>

@ -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}

@ -36,16 +36,6 @@ describe("<ColumnGrid>", () => {
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(
<ColumnGrid items={[item("a"), item("b")]} getKey={getKey} renderItem={(i) => <div key={i.id} />} maxItemHeight={360} />,
);
expect(withCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(2);
const noCap = render(<ColumnGrid items={[item("a"), item("b")]} getKey={getKey} renderItem={(i) => <div key={i.id} />} />);
expect(noCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(0);
});
it("renders nothing for an empty list", () => {
const { container } = render(<ColumnGrid items={[]} getKey={getKey} renderItem={() => <div data-testid="card" />} />);

@ -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("<MemoBody /> 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("<MemoBody /> 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(<MemoBody compact={true} />);
@ -83,4 +88,13 @@ describe("<MemoBody /> 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(<MemoBody compact={false} />);
expect(screen.queryByRole("button", { name: /memo\.show-more/ })).toBeNull();
});
});

@ -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);
});
});
Loading…
Cancel
Save