import { useCallback, useEffect, useRef, useState, type FormEvent, } from "react"; import { ArrowUp, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, MessageSquareText, Trash2, } from "lucide-react"; import { cn } from "@/openpress/core/cn"; import type { SourceBlock } from "../../document-model"; import { matchesHotkey } from "../../hotkeys"; import { isKeyboardEventComposing } from "../keyboardEvents"; import { MentionSuggestionList, useComposerMentions } from "../mentions"; import type { ProjectMentionItem } from "../project"; import { formatCommentTimestamp } from "../workbenchFormatters"; import type { InspectorCommentStatus, PendingCommentsStatus, } from "../workbenchTypes"; import type { PendingComment } from "./inspectorModel"; export interface CommentReviewDockProps { comments: PendingComment[]; status: PendingCommentsStatus; error: string; activeCommentId?: string | null; selectedBlock: SourceBlock | null; commentText: string; commentStatus: InspectorCommentStatus; commentStatusMessage: string; submitDisabled: boolean; mentionItems: ProjectMentionItem[]; onSelect: (comment: PendingComment) => void; onClear: (id: string) => Promise; onCommentTextChange: (value: string) => void; onSubmitComment: (event?: FormEvent) => Promise; } export function getCommentReviewPosition({ activeIndex, total, hasSelectedComment, hasDraftTarget, }: { activeIndex: number; total: number; hasSelectedComment: boolean; hasDraftTarget: boolean; }) { if (hasDraftTarget) return { current: total + 1, total: total + 1 }; if (hasSelectedComment) return { current: activeIndex + 1, total }; return { current: 0, total }; } export function CommentReviewDock({ comments, status, error, activeCommentId, selectedBlock, commentText, commentStatus, commentStatusMessage, submitDisabled, mentionItems, onSelect, onClear, onCommentTextChange, onSubmitComment, }: CommentReviewDockProps) { const [expanded, setExpanded] = useState(true); const [activeIndex, setActiveIndex] = useState(0); const textareaRef = useRef(null); const busy = status === "loading" || status === "clearing" || commentStatus === "submitting"; const total = comments.length; const selectedComment = activeCommentId ? comments.find((comment) => comment.id === activeCommentId) ?? null : null; const selectedPath = selectedComment?.path ?? selectedBlock?.path ?? ""; const selectedLine = selectedComment?.line ?? selectedBlock?.source?.line; const displayPath = selectedPath.split("/").slice(-3).join("/"); const editorLabel = selectedComment ? "編輯註解" : selectedBlock ? "新增註解" : "尚未選取內容"; const reviewPosition = getCommentReviewPosition({ activeIndex, total, hasSelectedComment: Boolean(selectedComment), hasDraftTarget: Boolean(selectedBlock && !selectedComment), }); const { activeMention, handleMentionKeyDown, highlightedMentionIndex, mentionSuggestions, setHighlightedMentionIndex, setComposerCursor, syncCursor, insertMention, } = useComposerMentions({ text: commentText, items: mentionItems, textareaRef, onTextChange: onCommentTextChange, enabled: expanded && Boolean(selectedBlock || selectedComment), }); useEffect(() => { if (total === 0) { setActiveIndex(0); return; } setActiveIndex((current) => Math.min(current, total - 1)); }, [total]); useEffect(() => { if (!activeCommentId) return; const index = comments.findIndex((comment) => comment.id === activeCommentId); if (index >= 0) setActiveIndex(index); }, [activeCommentId, comments]); const selectIndex = useCallback((index: number) => { if (total === 0) return; const nextIndex = (index + total) % total; const comment = comments[nextIndex]; if (!comment) return; setActiveIndex(nextIndex); setExpanded(true); onSelect(comment); }, [comments, onSelect, total]); const handlePrev = useCallback(() => selectIndex(activeIndex - 1), [activeIndex, selectIndex]); const handleNext = useCallback(() => selectIndex(activeIndex + 1), [activeIndex, selectIndex]); useEffect(() => { const firstComment = comments[0]; if (!firstComment || activeCommentId || selectedBlock) return; onSelect(firstComment); }, [activeCommentId, comments, onSelect, selectedBlock]); useEffect(() => { if (!selectedBlock && !selectedComment) return; setExpanded(true); const frame = window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true })); return () => window.cancelAnimationFrame(frame); }, [selectedBlock?.id, selectedComment?.id]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable) ) return; if (event.key === "j" || event.key === "J" || event.key === "ArrowDown" || event.key === "]") { event.preventDefault(); handleNext(); } else if (event.key === "k" || event.key === "K" || event.key === "ArrowUp" || event.key === "[") { event.preventDefault(); handlePrev(); } else if (event.key === "c" || event.key === "C" || event.key === "Enter" || event.key === "Escape") { event.preventDefault(); setExpanded((current) => !current); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleNext, handlePrev]); const handleClear = async () => { if (!selectedComment || busy) return; await onClear(selectedComment.id); const remaining = comments.filter((comment) => comment.id !== selectedComment.id); if (remaining.length === 0) return; const nextIndex = Math.min(activeIndex, remaining.length - 1); setActiveIndex(nextIndex); onSelect(remaining[nextIndex]); }; return (
{reviewPosition.current} / {reviewPosition.total}
{expanded ? (
void onSubmitComment(event)} > {error ? (

{error}

) : status === "loading" ? (

正在載入註解…

) : ( <>
{displayPath ? ( {displayPath}{selectedLine ? `:${selectedLine}` : ""} ) : null}