From 50d12ce974574c96882396dc3c62dceace0ba9f5 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 06:27:22 +0000 Subject: [PATCH] Anchor review annotations on the selected node text range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: RDM now anchors annotations on nodeId + textStart + textEnd + textHash (spec 06 §2) and acceptance 10 §5 requires accurate comment ranges. The workspace only sent whole-node annotations and ignored quote/needsReanchor. - Selecting text inside one node's title in the reader view records a code-point range (Array.from indices; surrogate pairs and ZWJ emoji are counted as runes, half-pair endpoints widen) and sends textStart/textEnd plus quote, so RDM rejects rather than misplaces a drifted range. - No selection keeps whole-node behavior; replies never carry range params; textHash/range are never sent; a range is dropped if it belongs to another node. - Selections spanning several nodes or containing no node text record no range and show a notice that the whole node will be annotated; the composer always shows the scope with a "whole node" escape. - Annotation items show the server quote and a visible needs-reanchor state; review_anchor_invalid: errors map to readable Chinese text. - Reader titles become role=button spans because browsers do not allow text selection inside } + {a.quote &&
{a.quote}
} + {a.needsReanchor && ( +
+ 原文已变化,批注范围不再匹配{a.quote ? ';上方为旧摘录' : ''}。留痕稿将其列入孤立批注附录,不会自动迁移 +
+ )}
{a.text}
{a.replies.length > 0 && (
@@ -593,7 +600,34 @@ function StructureCanvas({ tree, order, visible, selectedId, onSelect, badges, r ) } -function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts, revealSeq }: ViewProps) { +/** 选区与各 [data-anchor-node] 正文元素的交集;端点在元素外时裁到元素首尾。 */ +function selectionHits(root: HTMLElement, range: Range): SelectionHit[] { + const hits: SelectionHit[] = [] + for (const el of root.querySelectorAll('[data-anchor-node]')) { + if (!range.intersectsNode(el)) continue + const text = el.textContent ?? '' + const whole = document.createRange() + whole.selectNodeContents(el) + const offsetOf = (node: Node, offset: number) => { + const side = whole.comparePoint(node, offset) + if (side !== 0) return side < 0 ? 0 : text.length + const head = document.createRange() + head.setStart(el, 0) + head.setEnd(node, offset) + return head.toString().length + } + hits.push({ nodeId: el.dataset.anchorNode ?? '', text, start: offsetOf(range.startContainer, range.startOffset), end: offsetOf(range.endContainer, range.endOffset) }) + } + return hits +} + +type TextSelection = ReturnType + +// 标题用 role=button 的 span 而非 + {badges(id)} {n.state && {n.state}} @@ -631,10 +676,38 @@ function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts, // ---- 审阅输入 ---- -function ReviewComposer({ nodeTitle, replyTo, onCancelReply, canAnnotate, canRevise, busy, onAnnotate, onRevise }: { +/** 批注范围:回复沿用父批注范围;有选区显示摘录,否则为整个节点;选区无效时给出说明。 */ +export function AnnotationScope({ anchor, notice, replying, onClear }: { + anchor: TextAnchor | null + notice: string + replying: boolean + onClear: () => void +}) { + if (replying) return
回复沿用原批注的范围
+ return ( + <> +
+ 范围 + {anchor ? ( + <> + 「{anchor.quote}」 + 第 {anchor.textStart + 1}–{anchor.textEnd} 字 + + + ) : 整个节点(在阅读视图中选中该节点的文字,可只批注所选部分)} +
+ {notice &&
{notice}
} + + ) +} + +function ReviewComposer({ nodeTitle, replyTo, onCancelReply, anchor, anchorNotice, onClearAnchor, canAnnotate, canRevise, busy, onAnnotate, onRevise }: { nodeTitle: string | undefined replyTo: ReviewAnnotation | null onCancelReply: () => void + anchor: TextAnchor | null + anchorNotice: string + onClearAnchor: () => void canAnnotate: boolean canRevise: boolean busy: boolean @@ -681,6 +754,7 @@ function ReviewComposer({ nodeTitle, replyTo, onCancelReply, canAnnotate, canRev
)} +