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).
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user