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:
parent
9fae524221
commit
620db61f7c
13 changed files with 176 additions and 243 deletions
73
web/src/components/ClampedSection.tsx
Normal file
73
web/src/components/ClampedSection.tsx
Normal file
|
|
@ -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;
|
renderItem: (item: T) => ReactNode;
|
||||||
/** Optional node packed as the very first tile (e.g. the note composer). */
|
/** Optional node packed as the very first tile (e.g. the note composer). */
|
||||||
leading?: ReactNode;
|
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. */
|
/** Key that must land at the top of column one (e.g. a just-created memo), not the shortest column. */
|
||||||
priorityKey?: string;
|
priorityKey?: string;
|
||||||
/** Upper bound on the column count; 0 or undefined means as many as fit. */
|
/** 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 —
|
* 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.
|
* 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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const refCallbacks = useRef<Map<string, (el: HTMLDivElement | null) => void>>(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 });
|
if (el) ordered.push({ key, el });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 1 (writes): fix each card's width, drop its own bottom margin so spacing is owned
|
// Pass 1 (writes): fix each card's width and drop its own bottom margin so spacing is
|
||||||
// entirely by `gap`, and clear any prior cap so the natural height can be measured.
|
// owned entirely by `gap`. `firstElementChild` is the item's rendered root. Items are
|
||||||
// `firstElementChild` is the item's rendered root (the memo card, or the comment wrapper).
|
// expected to bound their own height — the grid never clips content itself.
|
||||||
for (const { el } of ordered) {
|
for (const { el } of ordered) {
|
||||||
el.style.width = `${columnWidth}px`;
|
el.style.width = `${columnWidth}px`;
|
||||||
const child = el.firstElementChild;
|
const child = el.firstElementChild;
|
||||||
if (child instanceof HTMLElement) {
|
if (child instanceof HTMLElement) {
|
||||||
child.style.marginBottom = "0px";
|
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,
|
// Pass 2 (reads): height of every card, measured once after the width writes so the
|
||||||
// not the absolutely-positioned wrapper (whose block-formatting context would fold in margins).
|
// browser reflows a single time. We read the inner element, not the absolutely-positioned
|
||||||
const naturalByKey = new Map<string, number>();
|
// wrapper (whose block-formatting context would fold in margins).
|
||||||
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.
|
|
||||||
const heightByKey = new Map<string, number>();
|
const heightByKey = new Map<string, number>();
|
||||||
for (const { key, el } of ordered) {
|
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;
|
const child = el.firstElementChild;
|
||||||
if (capped && child instanceof HTMLElement) {
|
heightByKey.set(key, child instanceof HTMLElement ? child.offsetHeight : el.offsetHeight);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
const heightOf = (key: string) => heightByKey.get(key) ?? 0;
|
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)));
|
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.
|
// Keep a stable reference so observer callbacks always run the latest layout.
|
||||||
const relayoutRef = useRef(relayout);
|
const relayoutRef = useRef(relayout);
|
||||||
|
|
@ -256,14 +234,6 @@ function ColumnGrid<T>({ items, getKey, renderItem, leading, maxItemHeight, prio
|
||||||
return (
|
return (
|
||||||
<div key={key} ref={getItemRef(key)} className="absolute top-0 left-0" style={{ willChange: "transform" }}>
|
<div key={key} ref={getItemRef(key)} className="absolute top-0 left-0" style={{ willChange: "transform" }}>
|
||||||
{renderItem(item)}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,6 @@ import { defaultSchema } from "rehype-sanitize";
|
||||||
export const TASK_LIST_CLASS = "contains-task-list";
|
export const TASK_LIST_CLASS = "contains-task-list";
|
||||||
export const TASK_LIST_ITEM_CLASS = "task-list-item";
|
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 = [
|
const TRUSTED_IFRAME_SRC_PATTERNS = [
|
||||||
/^https:\/\/www\.youtube\.com\/embed\/[^?#]+(?:\?.*)?$/i,
|
/^https:\/\/www\.youtube\.com\/embed\/[^?#]+(?:\?.*)?$/i,
|
||||||
/^https:\/\/www\.youtube-nocookie\.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 { memo, useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
|
||||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
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 { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
|
||||||
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
|
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
|
||||||
import type { MemoContentProps } from "./types";
|
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 MemoContent = (props: MemoContentProps) => {
|
||||||
const { className, contentClassName, content, onClick, onDoubleClick } = props;
|
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 mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
|
||||||
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
|
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
|
||||||
|
|
||||||
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
|
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
|
||||||
<div
|
<div
|
||||||
ref={memoContentContainerRef}
|
|
||||||
data-memo-content
|
data-memo-content
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full max-w-full wrap-break-word text-base leading-6",
|
"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).
|
// 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-ref]]:no-underline [&_[data-footnote-ref]:hover]:underline",
|
||||||
"[&_.data-footnote-backref]:no-underline [&_.data-footnote-backref:hover]:underline",
|
"[&_.data-footnote-backref]:no-underline [&_.data-footnote-backref:hover]:underline",
|
||||||
showCompactMode === "ALL" && "overflow-hidden",
|
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
style={showCompactMode === "ALL" ? { maxHeight: `${getPreviewMaxHeightPx()}px` } : undefined}
|
|
||||||
onMouseUp={onClick}
|
onMouseUp={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
|
|
@ -52,28 +41,7 @@ const MemoContent = (props: MemoContentProps) => {
|
||||||
memoName={props.memoName}
|
memoName={props.memoName}
|
||||||
compact={Boolean(props.compact)}
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ export interface MemoContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
|
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
|
||||||
memoName?: string;
|
memoName?: string;
|
||||||
|
/** The card renders collapsed (ClampedSection), so footnote links navigate instead of scrolling. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
onClick?: (e: React.MouseEvent) => void;
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentCompactView = "ALL" | "SNIPPET";
|
|
||||||
|
|
|
||||||
|
|
@ -35,19 +35,59 @@ function MemoDisplaySettingMenu({ className }: Props) {
|
||||||
setLinkPreview,
|
setLinkPreview,
|
||||||
setMaxColumns,
|
setMaxColumns,
|
||||||
} = useView();
|
} = 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
|
// 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.
|
// there; it only becomes a real choice at a single column.
|
||||||
const compactLocked = maxColumns !== 1;
|
const compactLocked = maxColumns !== 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<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" />
|
<Settings2Icon className="w-4 h-auto shrink-0" />
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
|
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
|
||||||
<div className="flex flex-col gap-2 p-1">
|
<div className="flex flex-col gap-2 p-1">
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<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>
|
<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
|
{/* 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. */}
|
fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */}
|
||||||
|
|
@ -102,47 +142,6 @@ function MemoDisplaySettingMenu({ className }: Props) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,13 @@ const MemoPreview = ({
|
||||||
<div className="text-sm text-muted-foreground truncate min-w-0">No content</div>
|
<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 (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import ClampedSection from "@/components/ClampedSection";
|
||||||
import { AttachmentListView, LocationDisplayView, RelationListView } from "@/components/MemoMetadata";
|
import { AttachmentListView, LocationDisplayView, RelationListView } from "@/components/MemoMetadata";
|
||||||
|
import { isReferenceRelation } from "@/components/MemoMetadata/Relation/relationHelpers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import MemoContent from "../../MemoContent";
|
import MemoContent from "../../MemoContent";
|
||||||
import { MemoReactionListView } from "../../MemoReactionListView";
|
import { MemoReactionListView } from "../../MemoReactionListView";
|
||||||
|
|
@ -24,7 +25,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
|
||||||
|
|
||||||
const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -34,17 +35,20 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
|
||||||
blurred && !showBlurredContent && "blur-lg transition-all duration-200",
|
blurred && !showBlurredContent && "blur-lg transition-all duration-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MemoContent
|
{/* Compact bounds the whole body — attachments included — behind one Show more.
|
||||||
key={memo.name}
|
Reactions stay outside so they never hide under the fade. */}
|
||||||
memoName={memo.name}
|
<ClampedSection enabled={Boolean(compact)}>
|
||||||
content={memo.content}
|
<MemoContent
|
||||||
onClick={handleMemoContentClick}
|
memoName={memo.name}
|
||||||
onDoubleClick={handleMemoContentDoubleClick}
|
content={memo.content}
|
||||||
compact={memo.pinned ? false : compact} // Always show full content when pinned
|
onClick={handleMemoContentClick}
|
||||||
/>
|
onDoubleClick={handleMemoContentDoubleClick}
|
||||||
<AttachmentListView attachments={memo.attachments} onImagePreview={openPreview} />
|
compact={Boolean(compact)}
|
||||||
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
/>
|
||||||
{memo.location && <LocationDisplayView location={memo.location} />}
|
<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} />
|
<MemoReactionListView memo={memo} reactions={memo.reactions} />
|
||||||
</div>
|
</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
|
// 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.
|
// grid centers in the leftover space instead of filling it.
|
||||||
const MAX_COLUMN_WIDTH = 420;
|
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.
|
// The grid packs cards into columns, so a card-shaped skeleton doesn't fit; use a spinner.
|
||||||
const GridLoader = () => (
|
const GridLoader = () => (
|
||||||
|
|
@ -271,7 +269,6 @@ const PagedMemoList = (props: Props) => {
|
||||||
getKey={getMemoKey}
|
getKey={getMemoKey}
|
||||||
renderItem={(memo) => props.renderer(memo, { compact: effectiveCompact })}
|
renderItem={(memo) => props.renderer(memo, { compact: effectiveCompact })}
|
||||||
leading={gridLeading}
|
leading={gridLeading}
|
||||||
maxItemHeight={MAX_ITEM_HEIGHT}
|
|
||||||
priorityKey={priorityKey}
|
priorityKey={priorityKey}
|
||||||
maxColumns={maxColumns}
|
maxColumns={maxColumns}
|
||||||
maxColumnWidth={MAX_COLUMN_WIDTH}
|
maxColumnWidth={MAX_COLUMN_WIDTH}
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,6 @@ describe("<ColumnGrid>", () => {
|
||||||
expect(grid.children[0].querySelector('[data-testid="composer"]')).not.toBeNull();
|
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", () => {
|
it("renders nothing for an empty list", () => {
|
||||||
const { container } = render(<ColumnGrid items={[]} getKey={getKey} renderItem={() => <div data-testid="card" />} />);
|
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 { 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";
|
import MemoBody from "@/components/MemoView/components/MemoBody";
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
|
|
@ -62,14 +63,18 @@ const createMemo = (content: string) => ({
|
||||||
reactions: [],
|
reactions: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("<MemoBody /> compact content", () => {
|
afterEach(() => {
|
||||||
it("keeps expanded compact content expanded when memo content changes", async () => {
|
vi.restoreAllMocks();
|
||||||
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: () => ({}) };
|
describe("<MemoBody /> compact body clamp", () => {
|
||||||
}
|
it("keeps a buffer between the preview height and the fold trigger", () => {
|
||||||
return { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}) };
|
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");
|
mockState.memo = createMemo("line 1\nline 2");
|
||||||
const { rerender } = render(<MemoBody compact={true} />);
|
const { rerender } = render(<MemoBody compact={true} />);
|
||||||
|
|
@ -83,4 +88,13 @@ describe("<MemoBody /> compact content", () => {
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /memo\.show-less/ })).toBeInTheDocument();
|
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…
Reference in a new issue