mirror of https://github.com/usememos/memos
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
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;
|
||||
@ -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,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…
Reference in New Issue