"use client"; import { useRef, type ReactNode, type RefObject } from "react"; import { locateInDraft } from "@/lib/review/locate"; import type { ReviewConflict } from "@/lib/review/sse"; interface AnnotatedTextProps { value: string; onChange: (next: string) => void; conflicts: ReviewConflict[]; // 当前聚焦的冲突下标(来自报告卡「跳转」/锚点),用于锚点高亮联动。 focusedIndex: number | null; onAnchorClick: (index: number) => void; // 转发给可编辑 textarea,供父组件 setSelectionRange 定位/高亮问题区域。 editorRef?: RefObject; } // 高亮叠层编辑器:背景层渲染带 高亮的正文,前景透明 textarea 负责编辑。 // 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位。 const TYPO = "box-border w-full font-serif text-[17px] leading-[1.9] tracking-normal whitespace-pre-wrap break-words"; // 把终稿按已定位的冲突区间切成 [纯文本 | | …]; 带 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, 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); return (
{anchorable.length > 0 ? (
{anchorable.map(({ c, i }) => ( ))}
) : null}
{/* 背景高亮层:在文档流中决定高度(撑满全文),仅展示(不可交互)。 */} {/* 前景编辑层:文字透明(由背景层显示),仅保留光标;无内部滚动(页面滚动)。 */}