"use client"; import { useRef, type ReactNode, type RefObject } from "react"; import Link from "next/link"; import { PenLine } from "lucide-react"; import { EmptyState } from "@/components/ui/EmptyState"; import { locateInDraft } from "@/lib/review/locate"; import { buttonClass, proseBody } from "@/lib/ui/variants"; import type { ReviewConflict } from "@/lib/review/sse"; interface AnnotatedTextProps { value: string; onChange: (next: string) => void; conflicts: ReviewConflict[]; // 当前聚焦的冲突下标(来自报告卡「跳转」/锚点),用于锚点高亮联动。 focusedIndex: number | null; onAnchorClick: (index: number) => void; // 空待审正文时,引导回写作页起草本章。 projectId: string; chapterNo: number; // 转发给可编辑 textarea,供父组件 setSelectionRange 定位/高亮问题区域。 editorRef?: RefObject; } // 高亮叠层编辑器:背景层渲染带 高亮的正文,前景透明 textarea 负责编辑。 // 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位;正文排版与写作页 Editor 一致(proseBody)。 const TYPO = `box-border w-full whitespace-pre-wrap break-words ${proseBody}`; // 把终稿按已定位的冲突区间切成 [纯文本 | | …]; 带 id 供父组件滚动定位。 // 区间按 start 排序,重叠区间保留先到者(跳过后者),避免标签嵌套。 function buildSegments(value: string, conflicts: ReviewConflict[]): ReactNode[] { const spans = conflicts .map((c, i) => ({ i, loc: locateInDraft(value, c) })) .filter( (x): x is { i: number; loc: { start: number; end: number } } => x.loc !== null, ) .sort((a, b) => a.loc.start - b.loc.start); const nodes: ReactNode[] = []; let cursor = 0; for (const { i, loc } of spans) { if (loc.start < cursor) continue; // 与已保留区间重叠 → 跳过 if (loc.start > cursor) nodes.push(value.slice(cursor, loc.start)); nodes.push( {value.slice(loc.start, loc.end)} , ); cursor = loc.end; } if (cursor < value.length) nodes.push(value.slice(cursor)); return nodes; } // 正文就地编辑 + 冲突锚点(UX §8.3 / §6.1)。 // 顶部一排朱砂锚点:每条冲突一枚,点击=定位到正文对应区域(父组件在 textarea 选中并滚动)。 // 正文本体=高亮叠层编辑器:可直接改稿;冲突原文以朱砂淡底持续高亮,点锚点滚到并闪烁。 export function AnnotatedText({ value, onChange, conflicts, focusedIndex, onAnchorClick, projectId, chapterNo, editorRef, }: AnnotatedTextProps) { const backdropRef = useRef(null); const segments = buildSegments(value, conflicts); // 只为能在正文里定位到的冲突渲染锚点(其余如涉及摘要/跨章的冲突不在正文,点了也定位不到)。 const anchorable = conflicts .map((c, i) => ({ c, i })) .filter(({ c }) => locateInDraft(value, c) !== null); // 空待审正文:不渲染高亮叠层,直接引导回写作页起草本章(避免对着空编辑器发懵)。 if (!value) { return (