feat(web): redesign memo detail sidebar as property rail
Rework MemoDetailSidebar into a Linear-style rail: a quiet icon action cluster (pin, copy link, share image, share links) followed by label/value property rows for visibility, created/edited times, author, and location, then hairline-separated sections for tags, attachments, relations, and the outline. Attachments and reference relations were previously not surfaced in the sidebar at all. - Visibility is editable in place, reusing VisibilitySelector with a new compact size variant that matches the 13px row grammar - Pin toggle and visibility changes go through useUpdateMemo - MemoOutline gains active-section tracking (scroll-spy), depth indentation, and tick-rail styling; drops the animated underline and native title tooltips - Widen the detail rail from w-56 to w-60
This commit is contained in:
@@ -1,14 +1,31 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { CheckCircleIcon, ChevronRightIcon, Code2Icon, HashIcon, ImageIcon, LinkIcon, type LucideIcon, Share2Icon } from "lucide-react";
|
||||
import { BookmarkIcon, FileTextIcon, ImageIcon, LinkIcon, MapPinIcon, MilestoneIcon, Share2Icon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
import VisibilitySelector from "@/components/MemoEditor/Toolbar/VisibilitySelector";
|
||||
import { getLocationDisplayText } from "@/components/MemoMetadata/Location/locationHelpers";
|
||||
import { getRelationBuckets, getRelationMemo, type RelationDirection } from "@/components/MemoMetadata/Relation/relationHelpers";
|
||||
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import VisibilityIcon from "@/components/VisibilityIcon";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { useUser } from "@/hooks/useUserQueries";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Memo, Memo_PropertySchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { type Translations, useTranslate } from "@/utils/i18n";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo, MemoRelation, Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { getAttachmentUrl } from "@/utils/attachment";
|
||||
import { formatFileSize } from "@/utils/format";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { extractHeadings } from "@/utils/markdown-manipulation";
|
||||
import { convertVisibilityToString } from "@/utils/memo";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import MemoOutline from "./MemoOutline";
|
||||
import MemoSharePanel from "./MemoSharePanel";
|
||||
@@ -19,116 +36,282 @@ interface Props {
|
||||
onShareImageOpen?: () => void;
|
||||
}
|
||||
|
||||
interface PropertyBadge {
|
||||
icon: LucideIcon;
|
||||
labelKey: Translations;
|
||||
}
|
||||
const SectionLabel = ({ label, count }: { label: string; count?: number }) => (
|
||||
<p className="flex items-baseline gap-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
|
||||
{label}
|
||||
{count != null && <span className="tabular-nums text-muted-foreground/35">{count}</span>}
|
||||
</p>
|
||||
);
|
||||
|
||||
const SidebarSection = ({ label, count, children }: { label: string; count?: number; children: React.ReactNode }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground/50 uppercase tracking-wider">{label}</p>
|
||||
{count != null && <span className="text-xs text-muted-foreground/30">({count})</span>}
|
||||
</div>
|
||||
const Section = ({ label, count, children }: { label: string; count?: number; children: React.ReactNode }) => (
|
||||
<div className="w-full flex flex-col gap-1.5 border-t border-border/60 pt-3">
|
||||
<SectionLabel label={label} count={count} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PROPERTY_BADGE_CLASSES =
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground";
|
||||
/** Quiet icon action for the cluster at the top of the rail. */
|
||||
const ActionButton = ({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex size-7 items-center justify-center rounded-md transition-colors",
|
||||
active ? "bg-primary/10 text-primary" : "text-muted-foreground/75 hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const TAG_BADGE_CLASSES =
|
||||
"inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer";
|
||||
const PropertyLabel = ({ children }: { children: React.ReactNode }) => (
|
||||
<span className="min-h-7 flex items-center text-[12.5px] text-muted-foreground/70">{children}</span>
|
||||
);
|
||||
|
||||
const SHARE_ACTION_ROW_CLASSES =
|
||||
"h-auto min-h-0 w-full justify-between rounded-none px-2 py-1.5 text-xs font-normal leading-tight text-muted-foreground transition-colors hover:bg-muted/40 hover:text-muted-foreground focus-visible:ring-offset-0 gap-1.5";
|
||||
const PROPERTY_VALUE_CLASSES = "min-w-0 flex items-center text-[13px] leading-5 text-foreground/85";
|
||||
|
||||
const RelationRow = ({ relation, direction, snippet }: { relation: MemoRelation; direction: RelationDirection; snippet: string }) => {
|
||||
const relatedMemo = getRelationMemo(relation, direction);
|
||||
if (!relatedMemo) {
|
||||
return null;
|
||||
}
|
||||
const Icon = direction === "referencing" ? LinkIcon : MilestoneIcon;
|
||||
return (
|
||||
<Link
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 -mx-1.5 text-[13px] leading-5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
to={`/${relatedMemo.name}`}
|
||||
title={snippet}
|
||||
viewTransition
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 opacity-60" />
|
||||
<span className="truncate">{snippet}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
|
||||
const isImage = attachment.type.startsWith("image");
|
||||
const Icon = isImage ? ImageIcon : FileTextIcon;
|
||||
const size = Number(attachment.size);
|
||||
return (
|
||||
<a
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 -mx-1.5 text-[13px] leading-5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
href={getAttachmentUrl(attachment)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={attachment.filename}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 opacity-60" />
|
||||
<span className="truncate">{attachment.filename}</span>
|
||||
{size > 0 && <span className="ml-auto shrink-0 pl-2 text-[11px] tabular-nums text-muted-foreground/45">{formatFileSize(size)}</span>}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const { profile } = useInstance();
|
||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
||||
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
||||
const property = create(Memo_PropertySchema, memo.property || {});
|
||||
|
||||
const readonly = memo.creator !== currentUser?.name && !isSuperUser(currentUser);
|
||||
const editable = !readonly && memo.state === State.NORMAL;
|
||||
const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser));
|
||||
const hasUpdated = !isEqual(memo.createTime, memo.updateTime);
|
||||
const headings = useMemo(() => extractHeadings(memo.content), [memo.content]);
|
||||
|
||||
const propertyBadges = useMemo(() => {
|
||||
const badges: PropertyBadge[] = [];
|
||||
if (property.hasLink) badges.push({ icon: LinkIcon, labelKey: "memo.links" });
|
||||
if (property.hasTaskList) badges.push({ icon: CheckCircleIcon, labelKey: "memo.to-do" });
|
||||
if (property.hasCode) badges.push({ icon: Code2Icon, labelKey: "memo.code" });
|
||||
return badges;
|
||||
}, [property.hasLink, property.hasTaskList, property.hasCode]);
|
||||
const { data: creator } = useUser(memo.creator, { enabled: !!memo.creator });
|
||||
const headings = useMemo(() => extractHeadings(memo.content), [memo.content]);
|
||||
const { referencing, referenced } = useMemo(() => getRelationBuckets(memo.relations, memo.name), [memo.relations, memo.name]);
|
||||
const resolvedMemos = useResolvedRelationMemos(memo.relations);
|
||||
|
||||
const createTime = memo.createTime ? timestampDate(memo.createTime) : undefined;
|
||||
const updateTime = memo.updateTime ? timestampDate(memo.updateTime) : undefined;
|
||||
const formatDate = (date: Date) => date.toLocaleDateString(i18n.language, { year: "numeric", month: "short", day: "numeric" });
|
||||
|
||||
const relationSnippet = (relation: MemoRelation, direction: RelationDirection) => {
|
||||
const relatedMemo = getRelationMemo(relation, direction);
|
||||
if (!relatedMemo) {
|
||||
return "";
|
||||
}
|
||||
return relatedMemo.snippet || resolvedMemos[relatedMemo.name]?.snippet || relatedMemo.name;
|
||||
};
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
await updateMemo({ update: { name: memo.name, pinned: !memo.pinned }, updateMask: ["pinned"] });
|
||||
};
|
||||
|
||||
const handleVisibilityChange = async (visibility: Visibility) => {
|
||||
if (visibility === memo.visibility) {
|
||||
return;
|
||||
}
|
||||
await updateMemo({ update: { name: memo.name, visibility }, updateMask: ["visibility"] });
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const host = profile.instanceUrl || window.location.origin;
|
||||
copy(`${host}/${memo.name}`);
|
||||
toast.success(t("message.succeed-copy-link"));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
|
||||
{headings.length > 0 && (
|
||||
<SidebarSection label={t("memo.outline")}>
|
||||
<MemoOutline headings={headings} />
|
||||
</SidebarSection>
|
||||
<aside
|
||||
className={cn(
|
||||
"relative w-full h-auto max-h-screen overflow-y-auto overflow-x-hidden flex flex-col gap-4 px-1.5 select-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-0.5 -mx-1.5">
|
||||
{editable && (
|
||||
<ActionButton label={memo.pinned ? t("common.unpin") : t("common.pin")} active={memo.pinned} onClick={handleTogglePin}>
|
||||
<BookmarkIcon className={cn("size-[15px]", memo.pinned && "fill-current")} />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton label={t("memo.copy-link")} onClick={handleCopyLink}>
|
||||
<LinkIcon className="size-[15px]" />
|
||||
</ActionButton>
|
||||
{onShareImageOpen && (
|
||||
<ActionButton label={t("memo.share.open-image")} onClick={onShareImageOpen}>
|
||||
<ImageIcon className="size-[15px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{canManageShares && (
|
||||
<ActionButton label={t("memo.share.open-panel")} onClick={() => setSharePanelOpen(true)}>
|
||||
<Share2Icon className="size-[15px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(canManageShares || onShareImageOpen) && (
|
||||
<SidebarSection label={t("memo.share.section-label")}>
|
||||
<div className="overflow-hidden rounded-md border border-border/50 bg-muted/20">
|
||||
{onShareImageOpen && (
|
||||
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={onShareImageOpen}>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground/90" />
|
||||
<span className="truncate">{t("memo.share.open-image")}</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
|
||||
</Button>
|
||||
)}
|
||||
{onShareImageOpen && canManageShares && <div className="border-t border-border/50" />}
|
||||
{canManageShares && (
|
||||
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={() => setSharePanelOpen(true)}>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Share2Icon className="size-3.5 shrink-0 text-muted-foreground/90" />
|
||||
<span className="truncate">{t("memo.share.open-panel")}</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
|
||||
</Button>
|
||||
)}
|
||||
{/* properties */}
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 border-t border-border/60 pt-3">
|
||||
<PropertyLabel>{t("common.visibility")}</PropertyLabel>
|
||||
{editable ? (
|
||||
<div className="-mx-1.5 min-w-0 justify-self-start">
|
||||
<VisibilitySelector size="compact" value={memo.visibility} onChange={handleVisibilityChange} />
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
) : (
|
||||
<span className={PROPERTY_VALUE_CLASSES}>
|
||||
<VisibilityIcon visibility={memo.visibility} className="w-[13px] mr-1.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as Parameters<typeof t>[0])}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<SidebarSection label={t("common.created-at")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-foreground/70">{memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "—"}</p>
|
||||
{hasUpdated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("common.last-updated-at")}: {memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : "—"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
<PropertyLabel>{t("common.created-at")}</PropertyLabel>
|
||||
<span className={cn(PROPERTY_VALUE_CLASSES, "tabular-nums")} title={createTime?.toLocaleString(i18n.language)}>
|
||||
{createTime ? formatDate(createTime) : "—"}
|
||||
</span>
|
||||
|
||||
{propertyBadges.length > 0 && (
|
||||
<SidebarSection label={t("common.properties")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{propertyBadges.map(({ icon: Icon, labelKey }) => (
|
||||
<span key={labelKey} className={PROPERTY_BADGE_CLASSES}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
{hasUpdated && updateTime && (
|
||||
<>
|
||||
<PropertyLabel>{t("common.last-updated-at")}</PropertyLabel>
|
||||
<span className={cn(PROPERTY_VALUE_CLASSES, "tabular-nums")} title={updateTime.toLocaleString(i18n.language)}>
|
||||
{formatDate(updateTime)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{creator && (
|
||||
<>
|
||||
<PropertyLabel>{t("common.author")}</PropertyLabel>
|
||||
<Link
|
||||
className={cn(
|
||||
PROPERTY_VALUE_CLASSES,
|
||||
"gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 justify-self-start hover:bg-accent transition-colors",
|
||||
)}
|
||||
to={`/u/${encodeURIComponent(creator.username)}`}
|
||||
viewTransition
|
||||
>
|
||||
<UserAvatar className="size-4 shrink-0 rounded-full border-none" avatarUrl={creator.avatarUrl} />
|
||||
<span className="truncate">{creator.displayName || creator.username}</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{memo.location && (
|
||||
<>
|
||||
<PropertyLabel>{t("common.location")}</PropertyLabel>
|
||||
<span className={PROPERTY_VALUE_CLASSES} title={getLocationDisplayText(memo.location)}>
|
||||
<MapPinIcon className="w-[13px] mr-1.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getLocationDisplayText(memo.location)}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{memo.tags.length > 0 && (
|
||||
<SidebarSection label={t("common.tags")} count={memo.tags.length}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Section label={t("common.tags")} count={memo.tags.length}>
|
||||
<div className="flex flex-wrap gap-x-0.5 gap-y-1 -mx-1.5">
|
||||
{memo.tags.map((tag) => (
|
||||
<span key={tag} className={TAG_BADGE_CLASSES}>
|
||||
<HashIcon className="w-3 h-3 opacity-50" />
|
||||
{tag}
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex min-w-0 items-center gap-0.5 rounded-md px-1.5 py-0.5 text-[13px] leading-5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="opacity-45">#</span>
|
||||
<span className="truncate">{tag}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{memo.attachments.length > 0 && (
|
||||
<Section label={t("common.attachments")} count={memo.attachments.length}>
|
||||
<div className="flex flex-col">
|
||||
{memo.attachments.map((attachment) => (
|
||||
<AttachmentRow key={attachment.name} attachment={attachment as Attachment} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(referencing.length > 0 || referenced.length > 0) && (
|
||||
<Section label={t("common.relations")} count={referencing.length + referenced.length}>
|
||||
<div className="flex flex-col">
|
||||
{referencing.map((relation) => (
|
||||
<RelationRow
|
||||
key={`referencing-${getRelationMemo(relation, "referencing")?.name}`}
|
||||
relation={relation}
|
||||
direction="referencing"
|
||||
snippet={relationSnippet(relation, "referencing")}
|
||||
/>
|
||||
))}
|
||||
{referenced.map((relation) => (
|
||||
<RelationRow
|
||||
key={`referenced-${getRelationMemo(relation, "referenced")?.name}`}
|
||||
relation={relation}
|
||||
direction="referenced"
|
||||
snippet={relationSnippet(relation, "referenced")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{headings.length > 0 && (
|
||||
<Section label={t("memo.outline")}>
|
||||
<MemoOutline headings={headings} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{sharePanelOpen && <MemoSharePanel memoName={memo.name} open={sharePanelOpen} onClose={() => setSharePanelOpen(false)} />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HeadingItem } from "@/utils/markdown-manipulation";
|
||||
|
||||
@@ -5,19 +6,51 @@ interface MemoOutlineProps {
|
||||
headings: HeadingItem[];
|
||||
}
|
||||
|
||||
const levelIndent: Record<number, string> = {
|
||||
1: "ml-0",
|
||||
2: "ml-3",
|
||||
3: "ml-6",
|
||||
4: "ml-8",
|
||||
};
|
||||
/** Distance from the viewport top of the "reading line" used to decide the active section. */
|
||||
const READING_LINE_OFFSET = 100;
|
||||
|
||||
/** Outline navigation for memo headings (h1–h4). */
|
||||
/** Outline navigation for memo headings (h1–h4) with active-section tracking. */
|
||||
const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
||||
const [activeSlug, setActiveSlug] = useState<string | null>(null);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
const minLevel = useMemo(() => Math.min(...headings.map((heading) => heading.level)), [headings]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
rafRef.current = 0;
|
||||
let current: string | null = null;
|
||||
for (const heading of headings) {
|
||||
const el = document.getElementById(heading.slug);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top > READING_LINE_OFFSET) break;
|
||||
current = heading.slug;
|
||||
}
|
||||
setActiveSlug(current ?? headings[0]?.slug ?? null);
|
||||
};
|
||||
const requestUpdate = () => {
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(update);
|
||||
}
|
||||
};
|
||||
update();
|
||||
// Capture-phase listener so scrolls of any nested container are observed too.
|
||||
window.addEventListener("scroll", requestUpdate, true);
|
||||
window.addEventListener("resize", requestUpdate);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", requestUpdate, true);
|
||||
window.removeEventListener("resize", requestUpdate);
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
}, [headings]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(slug);
|
||||
if (el) {
|
||||
setActiveSlug(slug);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.replaceState(null, "", `#${slug}`);
|
||||
}
|
||||
@@ -25,26 +58,31 @@ const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
||||
|
||||
return (
|
||||
<nav className="relative flex flex-col">
|
||||
{headings.map((heading, index) => (
|
||||
<a
|
||||
key={`${heading.slug}-${index}`}
|
||||
href={`#${heading.slug}`}
|
||||
onClick={(e) => handleClick(e, heading.slug)}
|
||||
className={cn(
|
||||
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate",
|
||||
"text-muted-foreground/60 hover:text-foreground/90",
|
||||
"transition-colors duration-200 ease-out",
|
||||
levelIndent[heading.level],
|
||||
heading.level === 1 && "font-medium text-muted-foreground/80",
|
||||
)}
|
||||
title={heading.text}
|
||||
>
|
||||
<span className="relative">
|
||||
{headings.map((heading, index) => {
|
||||
const active = heading.slug === activeSlug;
|
||||
return (
|
||||
<a
|
||||
key={`${heading.slug}-${index}`}
|
||||
href={`#${heading.slug}`}
|
||||
onClick={(e) => handleClick(e, heading.slug)}
|
||||
aria-current={active ? "location" : undefined}
|
||||
className={cn(
|
||||
"relative block truncate rounded-md py-[3px] pr-1.5 -mx-1.5 text-[13px] leading-5 transition-colors",
|
||||
heading.level === minLevel && "font-medium",
|
||||
active ? "text-foreground" : "text-muted-foreground/70 hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
style={{ paddingLeft: 14 + (heading.level - minLevel) * 12 }}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-1.5 top-1/2 h-[13px] w-[2px] -translate-y-1/2 rounded-full transition-colors",
|
||||
active ? "bg-primary" : "bg-border",
|
||||
)}
|
||||
/>
|
||||
{heading.text}
|
||||
<span className="absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" />
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import VisibilityIcon from "@/components/VisibilityIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import type { VisibilitySelectorProps } from "../types";
|
||||
|
||||
const VisibilitySelector = (props: VisibilitySelectorProps) => {
|
||||
const { value, onChange } = props;
|
||||
const compact = props.size === "compact";
|
||||
const t = useTranslate();
|
||||
|
||||
const visibilityOptions = [
|
||||
@@ -21,12 +23,17 @@ const VisibilitySelector = (props: VisibilitySelectorProps) => {
|
||||
<DropdownMenu onOpenChange={props.onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button className="inline-flex items-center h-8 px-2 rounded-md text-sm text-muted-foreground hover:bg-accent transition-colors" />
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md hover:bg-accent transition-colors",
|
||||
compact ? "px-1.5 py-[3px] text-[13px] leading-5 text-foreground/85" : "h-8 px-2 text-sm text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VisibilityIcon visibility={value} className="opacity-60 mr-1.5" />
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
|
||||
<VisibilityIcon visibility={value} className={cn("opacity-60 mr-1.5", compact && "w-[13px]")} />
|
||||
<span className="truncate">{currentLabel}</span>
|
||||
<ChevronDownIcon className={cn("ml-0.5 opacity-60", compact ? "size-3.5 text-muted-foreground/70" : "w-4 h-4")} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{visibilityOptions.map((option) => (
|
||||
|
||||
@@ -79,4 +79,6 @@ export interface VisibilitySelectorProps {
|
||||
value: Visibility;
|
||||
onChange: (visibility: Visibility) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** "compact" renders a 13px trigger that blends into dense surfaces like the memo detail rail. */
|
||||
size?: "default" | "compact";
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"archive": "Archive",
|
||||
"archived": "Archived",
|
||||
"attachments": "Attachments",
|
||||
"author": "Author",
|
||||
"auto-expand": "Auto expand",
|
||||
"avatar": "Avatar",
|
||||
"basic": "Basic",
|
||||
@@ -106,8 +107,8 @@
|
||||
"delete": "Delete",
|
||||
"description": "Description",
|
||||
"edit": "Edit",
|
||||
"empty-placeholder": "Empty",
|
||||
"email": "Email",
|
||||
"empty-placeholder": "Empty",
|
||||
"expand": "Expand",
|
||||
"explore": "Explore",
|
||||
"file": "File",
|
||||
@@ -121,6 +122,7 @@
|
||||
"last-updated-at": "Last updated at",
|
||||
"learn-more": "Learn more",
|
||||
"link": "Link",
|
||||
"location": "Location",
|
||||
"map": "Map",
|
||||
"mark": "Mark",
|
||||
"memo": "Memo",
|
||||
|
||||
@@ -94,8 +94,8 @@ const MemoDetail = () => {
|
||||
</MobileHeader>
|
||||
)}
|
||||
<MentionResolutionProvider contents={mentionResolutionContents}>
|
||||
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
|
||||
<div className={cn("w-full md:w-[calc(100%-15rem)]")}>
|
||||
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-6")}>
|
||||
<div className={cn("w-full md:w-[calc(100%-16.5rem)]")}>
|
||||
{parentMemo && (
|
||||
<div className="w-auto inline-block mb-2">
|
||||
<Link
|
||||
@@ -130,7 +130,7 @@ const MemoDetail = () => {
|
||||
/>
|
||||
</div>
|
||||
{md && (
|
||||
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
|
||||
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-60 h-full">
|
||||
<MemoDetailSidebar className="py-6" memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user