Files
writer-work-flow/apps/web/components/review/AnnotatedText.tsx
Yaojia Wang 29265fa0fd feat(web): 审稿改稿闭环——一键采纳/AI改写/正文高亮定位编辑/伏笔确认/草稿自动保存/章节目录
- 冲突「采纳改法」:有补丁瞬时改入终稿并选中;无补丁但可定位则采纳时调 AI(refiner)重写该段套入;定位不到提示手改
- 正文改为行内高亮叠层编辑器(与页面同底色、随内容增高、无滚动条),点冲突卡/锚点整页滚到并选中+闪烁
- 仅对可在正文定位的冲突显示「跳转」/锚点;locate 支持从 where 引号抽原文,杜绝定位失败刷屏
- 伏笔建议卡:新埋一键登记(预填表单)/ 疑似回收一键标记 CLOSED(复用现有端点)
- 审稿页草稿自动保存(防抖 PUT /draft),与「验收本章」解耦,刷新不丢
- 写作路由带章号 ?chapter=N + 验收成功面板/大纲页「写下一章」入口;写作目录从大纲列出全部章节

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:28:29 +02:00

129 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<HTMLTextAreaElement | null>;
}
// 高亮叠层编辑器:背景层渲染带 <mark> 高亮的正文,前景透明 textarea 负责编辑。
// 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位。
const TYPO =
"box-border w-full font-serif text-[17px] leading-[1.9] tracking-normal whitespace-pre-wrap break-words";
// 把终稿按已定位的冲突区间切成 [纯文本 | <mark> | …]<mark> 带 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(
<mark
key={`mark-${i}`}
id={`draft-mark-${i}`}
className="draft-mark text-ink"
>
{value.slice(loc.start, loc.end)}
</mark>,
);
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<HTMLDivElement>(null);
const segments = buildSegments(value, conflicts);
// 只为能在正文里定位到的冲突渲染锚点(其余如涉及摘要/跨章的冲突不在正文,点了也定位不到)。
const anchorable = conflicts
.map((c, i) => ({ c, i }))
.filter(({ c }) => locateInDraft(value, c) !== null);
return (
<div className="flex flex-col gap-3">
{anchorable.length > 0 ? (
<div className="flex flex-wrap gap-2 border-b border-line pb-3">
{anchorable.map(({ c, i }) => (
<button
key={`${c.type}-${c.where}-${i}`}
type="button"
id={`anchor-${i}`}
onClick={() => onAnchorClick(i)}
aria-current={focusedIndex === i ? "true" : undefined}
className={`conflict-anchor rounded px-2 py-0.5 text-xs ${
focusedIndex === i
? "bg-cinnabar text-panel"
: "text-conflict hover:bg-[var(--color-conflict)]/10"
}`}
title={`${c.type}${c.where || "正文"} — 点击定位到正文`}
>
<span aria-hidden="true"></span> {c.type}
{c.where ? ` · ${c.where}` : ""}
</button>
))}
</div>
) : null}
<label htmlFor="final-text" className="sr-only">
稿
</label>
<div className="relative min-h-[40vh] w-full bg-transparent">
{/* 背景高亮层:在文档流中决定高度(撑满全文),仅展示(不可交互)。 */}
<div
ref={backdropRef}
data-draft-backdrop
aria-hidden="true"
className={`${TYPO} pointer-events-none relative select-none text-ink`}
>
{value ? (
segments
) : (
<span className="text-ink-soft/60">
</span>
)}
{"\n"}
</div>
{/* 前景编辑层:文字透明(由背景层显示),仅保留光标;无内部滚动(页面滚动)。 */}
<textarea
ref={editorRef}
id="final-text"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
className={`${TYPO} absolute inset-0 resize-none overflow-hidden border-0 bg-transparent text-transparent caret-[color:var(--color-ink)] outline-none focus:outline-none`}
/>
</div>
</div>
);
}