Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
710d8dc75f fix(issues): keep sticky comment highlight consistent 2026-06-09 17:37:08 +08:00
Naiyuan Qing
1e720d3644 fix(issues): align sticky comment header padding 2026-06-09 17:09:29 +08:00

View File

@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import { memo, useCallback, useRef, useState, type ReactNode } from "react";
import { CheckCircle2, ChevronRight, ListChevronsDownUp, Copy, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
@@ -43,6 +43,41 @@ import { useT } from "../../i18n";
import { CommentsFoldBar } from "./resolved-thread-bar";
import { deriveThreadResolution } from "./thread-utils";
const highlightedCommentBackgroundClass =
"bg-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
const highlightedCommentFadeClass =
"after:from-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
function StickyHeaderShell({
className,
sticky = true,
highlighted,
children,
}: {
className?: string;
sticky?: boolean;
highlighted?: boolean;
children: ReactNode;
}) {
if (!sticky) {
return <div className={className}>{children}</div>;
}
return (
<div
className={cn(
"sticky top-0 z-10 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-2 after:bg-gradient-to-b after:to-transparent",
highlighted ? highlightedCommentBackgroundClass : "bg-card",
highlighted ? highlightedCommentFadeClass : "after:from-card",
)}
>
<div className={className}>
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -335,6 +370,7 @@ function CommentRow({
currentUserId,
canModerate = false,
isResolution = false,
isHighlighted = false,
onEdit,
onDelete,
onToggleReaction,
@@ -348,6 +384,8 @@ function CommentRow({
canModerate?: boolean;
/** True when this reply is the thread's resolution (shows the green badge). */
isResolution?: boolean;
/** True when this row is the deep-link target currently being highlighted. */
isHighlighted?: boolean;
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
@@ -369,12 +407,15 @@ function CommentRow({
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
return (
<div className="py-3">
<div className="py-1.5">
{/* Header pins to the timeline's scroll parent within this reply's own
row (the py-3 box is its containing block), so a LONG reply keeps its
row box, so a LONG reply keeps its
author + actions visible while you scroll its body, then releases once
this reply ends. bg-card occludes the body scrolling underneath. */}
<div className="sticky top-0 z-10 flex items-center gap-2.5 bg-card">
<StickyHeaderShell
highlighted={isHighlighted}
className="flex items-center gap-2.5 px-4 pt-1 pb-1.5"
>
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
<span className="cursor-pointer text-sm font-medium">
{getActorName(entry.actor_type, entry.actor_id)}
@@ -466,12 +507,12 @@ function CommentRow({
onConfirm={() => onDelete(entry.id)}
/>
</div>
</div>
</StickyHeaderShell>
{edit.editing ? (
<div
{...edit.dropZoneProps}
className="relative mt-1.5 pl-8"
className="relative pl-12 pr-4"
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
>
<div className="text-sm leading-relaxed">
@@ -520,17 +561,17 @@ function CommentRow({
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<div className="pl-12 pr-4 text-sm leading-relaxed text-foreground/85">
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-12 pr-4" />
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
className="mt-1.5 pl-12 pr-4"
/>
</>
)}
@@ -615,7 +656,7 @@ function CommentCardImpl({
// overflow-clip (not -hidden) clips the rounded corners WITHOUT creating a
// scroll container, so the sticky collapse affordances below resolve to the
// timeline's scroll parent instead of this card. See PR #3623.
<Card className={cn("!py-0 !gap-0 overflow-clip transition-colors duration-700", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Card className={cn("!py-0 !gap-0 overflow-clip transition-colors duration-700", isHighlighted && "ring-2 ring-brand/50", isHighlighted && highlightedCommentBackgroundClass)}>
{onCollapseResolved && (
<button
type="button"
@@ -636,7 +677,11 @@ function CommentCardImpl({
stays stuck behind every reply. */}
<div>
{/* Header — always visible, acts as toggle */}
<div className={cn("px-4 py-3", stickyHeader && "sticky top-0 z-10 bg-card")}>
<StickyHeaderShell
sticky={stickyHeader}
highlighted={isHighlighted}
className="px-4 py-3"
>
<div className="flex items-center gap-2.5">
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
@@ -739,7 +784,7 @@ function CommentCardImpl({
</div>
)}
</div>
</div>
</StickyHeaderShell>
{/* Collapsible body */}
<CollapsibleContent>
@@ -832,7 +877,7 @@ function CommentCardImpl({
</div>
)}
{resolutionReply && (
<div id={`comment-${resolutionReply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === resolutionReply.id && "bg-brand/5")}>
<div id={`comment-${resolutionReply.id}`} className={cn("border-t border-border/50 transition-colors duration-700", highlightedCommentId === resolutionReply.id && highlightedCommentBackgroundClass)}>
<CommentRow
issueId={issueId}
entry={resolutionReply}
@@ -840,6 +885,7 @@ function CommentCardImpl({
currentUserId={currentUserId}
canModerate={canModerate}
isResolution
isHighlighted={highlightedCommentId === resolutionReply.id}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}
@@ -864,7 +910,7 @@ function CommentCardImpl({
)}
{/* Replies — chronological; the resolution keeps its place with a badge */}
{allNestedReplies.map((reply) => (
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 transition-colors duration-700", highlightedCommentId === reply.id && highlightedCommentBackgroundClass)}>
<CommentRow
issueId={issueId}
entry={reply}
@@ -872,6 +918,7 @@ function CommentCardImpl({
currentUserId={currentUserId}
canModerate={canModerate}
isResolution={reply.id === replyResolutionId}
isHighlighted={highlightedCommentId === reply.id}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}