Anchor review annotations on the selected node text range
governance / governance (pull_request) Successful in 15s

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 <button>.

Evidence: npm test 40 files / 486 tests pass (30 new in
test/review-annotation-anchor.test.ts); npm run build exit 0;
node scripts/check-knip.mjs "50 <= 51"; design-guard 7/7; playwright
--list compiles 13 tests; headless Chromium check of the selection reader
(single, multi-node, triple-click clamp, emoji offsets).

Impact: document review UI only; base-gate decision source anchoring is
unchanged. Offsets are computed on the displayed node title (no frozen-text
read endpoint exists), guarded by quote equality on the server.

Refs: unified renderer plan Task 13; phase1 F2; spec 06 §2
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-10-18 06:27:22 +00:00
co-authored by Cursor
parent 0e05624823
commit 50d12ce974
5 changed files with 397 additions and 17 deletions
+32
View File
@@ -19,6 +19,7 @@ const UNCTRL = { doc: env('RBA_UNCTRL_DOC'), title: env('RBA_UNCTRL_TITLE') }
const GENERAL = { id: env('RBA_GENERAL_PROFILE'), version: env('RBA_GENERAL_VERSION') }
const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}`
const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标`
const RANGE_ANNOTATION = `§11 范围批注 ${RUN}:「目标」需可量化`
const REVISION_AFTER = `§11 修订 ${RUN}:目标改为三项可量化指标`
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !OUT, 'set PLAYWRIGHT_BASE_URL and RBA_OUT (browser-acceptance.sh drives this suite)')
@@ -433,6 +434,37 @@ test('§11-5b 审核人添加批注与修订', async ({ browser }) => {
await expect(review.locator('.drw-diff-add', { hasText: REVISION_AFTER })).toBeVisible({ timeout: 30_000 })
await expect(review.locator('.drw-anno-group .drw-anno-node').first()).toHaveText('项目内容、目标及意义')
await expect(page.locator('.drw-canvas-node[title="项目内容、目标及意义"] .drw-badge.review').first()).toBeVisible()
// 范围批注(06 §2):阅读视图选中节点正文「目标」(码点 5..7),载荷带偏移与摘录,不带 textHash/range。
await page.getByRole('tab', { name: '阅读视图' }).click()
const nodeText = page.locator('section.drw-reader-node.sel [data-anchor-node]')
await expect(nodeText).toHaveText('项目内容、目标及意义')
await nodeText.evaluate(el => {
const range = document.createRange()
range.setStart(el.firstChild!, 5)
range.setEnd(el.firstChild!, 7)
const sel = window.getSelection()!
sel.removeAllRanges()
sel.addRange(range)
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
})
await expect(review.locator('.drw-composer-quote')).toHaveText('「目标」')
await review.getByLabel('批注内容').fill(RANGE_ANNOTATION)
await review.getByRole('button', { name: '添加批注' }).click()
const annotateCalls = () => s.net.filter(n => n.url.includes('/api/commands/rdm.render.review.annotate') && parseJson(n.request)?.params?.text === RANGE_ANNOTATION)
await expect.poll(() => annotateCalls().length, { message: '应分发范围批注', timeout: 30_000 }).toBeGreaterThan(0)
const rangeCall = annotateCalls().at(-1)!
const rangeParams = parseJson(rangeCall.request)?.params
expect(rangeParams, '范围批注载荷').toMatchObject({ textStart: 5, textEnd: 7, quote: '目标' })
expect(rangeParams).not.toHaveProperty('textHash')
expect(rangeParams).not.toHaveProperty('range')
note(`范围批注回执:${rangeCall.body.slice(0, 300)}`)
const rangeItem = review.locator('.drw-anno', { hasText: RANGE_ANNOTATION })
const anchored = await rangeItem.locator('.drw-anno-quote').waitFor({ timeout: 30_000 }).then(() => true, () => false)
if (anchored) await expect(rangeItem.locator('.drw-anno-quote')).toHaveText('目标')
expect.soft(anchored, `GAP[RDM] 范围批注未按所选文字保存(回执:${rangeCall.body.slice(0, 200)})`).toBe(true)
await page.getByRole('tab', { name: '结构画布' }).click()
await expectReadableFailures(page)
await shot(page, 'item5b-review-annotations')
await s.close()
+112 -11
View File
@@ -16,14 +16,14 @@ import {
groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, locateNodeId, mergeDeliveries, moveSelection,
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, normalizeRenderTrace,
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope,
reviewTargetNode, revisePayload, submissionIdOf, traceAnchor, traceEndpointLabel, traceTypeLabel, truncateMiddle,
reviewTargetNode, revisePayload, selectionAnchor, submissionIdOf, traceAnchor, traceEndpointLabel, traceTypeLabel, truncateMiddle,
validateReviewText, visibleNodeIds, visibleOrder, walkDocTree,
decisionEntries, decisionPayload, normalizeDocSubmission, currentBindings, bindableRules, normalizeCandidateRules,
profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel, createSingleFlight,
type BindSource, type DecisionKind, type RuleRef,
type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind,
type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type RenderTrace,
type RenderTraceEntry, type ReviewAnnotation, type ReviewRevision,
type RenderTraceEntry, type ReviewAnnotation, type ReviewRevision, type SelectionHit, type TextAnchor,
} from '../documentRenderPresentation'
// ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ----
@@ -170,12 +170,19 @@ function Byline({ name, at }: { name: string; at: string }) {
function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: ReviewAnnotation) => void }) {
return (
<div className={`drw-anno${a.resolved ? ' resolved' : ''}`}>
<div className={`drw-anno${a.resolved ? ' resolved' : ''}${a.needsReanchor ? ' needs-reanchor' : ''}`}>
<div className="drw-anno-head">
<Byline name={a.author.displayName} at={a.createdAt} />
{a.needsReanchor && <Chip tone="warn">需重新锚定</Chip>}
{a.resolved && <Chip tone="ok">已解决</Chip>}
{onReply && <button type="button" className="drw-link" onClick={() => onReply(a)}>回复</button>}
</div>
{a.quote && <blockquote className="drw-anno-quote" title={a.quote}>{a.quote}</blockquote>}
{a.needsReanchor && (
<div className="drw-anno-reanchor">
原文已变化,批注范围不再匹配{a.quote ? ';上方为旧摘录' : ''}。留痕稿将其列入孤立批注附录,不会自动迁移
</div>
)}
<div className="drw-anno-text">{a.text}</div>
{a.replies.length > 0 && (
<div className="drw-anno-replies">
@@ -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<HTMLElement>('[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<typeof selectionAnchor>
// 标题用 role=button 的 span 而非 <button>:浏览器不允许拖选按钮内文字,范围批注需要选中标题正文。
function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts, revealSeq, onTextSelect }: ViewProps & {
/** 提供时节点正文可选中并按选区记录批注范围(有审阅提交且可批注)。 */
onTextSelect?: (selection: TextSelection) => void
}) {
const ref = useRef<HTMLDivElement>(null)
const wantFocus = useFocusSelected(ref, selectedId, revealSeq)
const onKey = (e: KeyboardEvent) => {
@@ -603,20 +637,31 @@ function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts,
wantFocus.current = true
onSelect(next)
}
const onMouseUp = () => {
if (!onTextSelect || !ref.current) return
const sel = window.getSelection()
onTextSelect(selectionAnchor(!sel || sel.rangeCount === 0 || sel.isCollapsed ? null : selectionHits(ref.current, sel.getRangeAt(0))))
}
return (
<div ref={ref} className="drw-reader" onKeyDown={onKey}>
<div ref={ref} className="drw-reader" onKeyDown={onKey} onMouseUp={onMouseUp}>
{visibleOrder(tree, visible).map(id => {
const n = tree.nodes[id]
const level = Math.min(n.depth + 2, 5)
const Heading = `h${level}` as 'h2' | 'h3' | 'h4' | 'h5'
const f = facts[id]
const activate = (e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
onSelect(id)
}
return (
<section key={id} id={nodeAnchorId(id)} className={`drw-reader-node${selectedId === id ? ' sel' : ''}`}>
<Heading className="drw-reader-heading">
<button type="button" className="drw-reader-title" data-node-id={id} aria-current={selectedId === id ? 'true' : undefined}
tabIndex={selectedId === id || (!selectedId && id === tree.rootId) ? 0 : -1} onClick={() => onSelect(id)}>
<span role="button" className="drw-reader-title" data-node-id={id} aria-current={selectedId === id ? 'true' : undefined}
data-anchor-node={onTextSelect && n.kind !== 'doc' ? id : undefined}
tabIndex={selectedId === id || (!selectedId && id === tree.rootId) ? 0 : -1} onClick={() => onSelect(id)} onKeyDown={activate}>
{n.title}
</button>
</span>
<a className="drw-anchor" href={`#${nodeAnchorId(id)}`} aria-label={`${n.title} 的锚点`}></a>
{badges(id)}
{n.state && <Chip tone={n.state === 'baselined' ? 'ok' : 'plain'}>{n.state}</Chip>}
@@ -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 <div className="drw-composer-scope drw-muted">回复沿用原批注的范围</div>
return (
<>
<div className="drw-composer-scope">
<span className="drw-muted">范围</span>
{anchor ? (
<>
<span className="drw-composer-quote" title={anchor.quote}>「{anchor.quote}」</span>
<span className="drw-muted">第 {anchor.textStart + 1}–{anchor.textEnd} 字</span>
<button type="button" className="drw-link" onClick={onClear}>改为整个节点</button>
</>
) : <span className="drw-muted">整个节点(在阅读视图中选中该节点的文字,可只批注所选部分)</span>}
</div>
{notice && <div className="drw-anchor-notice" role="status">{notice}</div>}
</>
)
}
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
<button type="button" className="drw-link" onClick={onCancelReply}>取消回复</button>
</div>
)}
<AnnotationScope anchor={anchor} notice={anchorNotice} replying={!!replyTo} onClear={onClearAnchor} />
<textarea className="ui-input drw-textarea" aria-label="批注内容" value={text} onChange={e => setText(e.target.value)} placeholder="写下批注…" />
</>
) : (
@@ -723,6 +797,8 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
const [profileKeyChoice, setProfileKeyChoice] = useState<string | null>(null)
const [submissionChoice, setSubmissionChoice] = useState<string | null>(null)
const [replyTo, setReplyTo] = useState<ReviewAnnotation | null>(null)
// 阅读视图选区记录的批注范围或无效说明;只对记录时的那个节点生效。
const [textPick, setTextPick] = useState<{ nodeId: string; anchor: TextAnchor | null; notice: string } | null>(null)
const [receipt, setReceipt] = useState<Record<string, any> | null>(null)
const sections = useGuardedResource(`${scope}:tree`, identity, live => loadDocTree(projectId, docId, live))
@@ -834,10 +910,33 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
const selectedNode = tree && selectedId ? tree.nodes[selectedId] : undefined
const reviewTarget = reviewTargetNode(tree, selectedId)
const reviewDone = () => { review.reload(); trace.reload() }
const pick = textPick && reviewTarget && textPick.nodeId === reviewTarget.id ? textPick : null
const acceptTextSelection = (selection: TextSelection) => {
switch (selection.kind) {
case 'none':
setTextPick(null)
return
case 'range':
selectNode(selection.anchor.nodeId)
setReplyTo(null)
setTextPick({ nodeId: selection.anchor.nodeId, anchor: selection.anchor, notice: '' })
return
case 'multi-node':
case 'outside-text':
setTextPick(reviewTarget ? { nodeId: reviewTarget.id, anchor: null, notice: selection.notice } : null)
return
default: {
const unknown: never = selection
return unknown
}
}
}
const annotate = (text: string, reply?: string) => {
if (!submissionId || !reviewTarget) return
const range = reply ? null : pick?.anchor ?? null
reviewOp.run('annotate', operationId => api.rdmCommand('rdm.render.review.annotate',
annotatePayload({ projectId, submissionId, treeNodeId: reviewTarget.id, text, operationId, replyTo: reply })), () => { setReplyTo(null); reviewDone() })
annotatePayload({ projectId, submissionId, treeNodeId: reviewTarget.id, text, operationId, replyTo: reply, range })),
() => { setReplyTo(null); setTextPick(null); reviewDone() })
}
const revise = (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => {
if (!submissionId || !reviewTarget) return
@@ -903,7 +1002,8 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
{order.length <= 1 && tree.order.length > 1 && <div className="empty-note">没有符合过滤条件的节点</div>}
{view === 'canvas'
? <StructureCanvas tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} revealSeq={revealSeq} />
: <ReaderView tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} revealSeq={revealSeq} />}
: <ReaderView tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} revealSeq={revealSeq}
onTextSelect={caps.review && caps.annotate && submissionId ? acceptTextSelection : undefined} />}
{selectedNode && (
<div className="drw-selected">
<SectionLabel>当前节点</SectionLabel>
@@ -992,6 +1092,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
)}
{(caps.annotate || caps.revise) && (
<ReviewComposer nodeTitle={reviewTarget?.title} replyTo={replyTo} onCancelReply={() => setReplyTo(null)}
anchor={pick?.anchor ?? null} anchorNotice={pick?.notice ?? ''} onClearAnchor={() => setTextPick(null)}
canAnnotate={caps.annotate} canRevise={caps.revise} busy={reviewOp.state.status === 'running'}
onAnnotate={annotate} onRevise={revise} />
)}
+79 -6
View File
@@ -104,10 +104,28 @@ const REFUSAL_MESSAGES: { code: string; message: string }[] = [
{ code: 'project source preflight', message: '审签来源尚未通过校验(来源消息已记录),请稍后重试;审签未生效' },
]
/** rdm.render.review.annotate 的范围校验失败(消息以 review_anchor_invalid: 开头;成对缺失另有固定文案)。 */
const ANCHOR_FAILURES: { match: RegExp; message: string }[] = [
{ match: /has no frozen text a range can anchor/, message: '该节点没有可按范围批注的正文(如章节容器、图片或空字段),请改为批注整个节点' },
{ match: /quote does not match/, message: '所选文字与提交审阅时冻结的正文不一致,请改为批注整个节点或重新选择' },
{ match: /quote occurs more than once/, message: '所选文字在该节点中出现多次,无法唯一定位,请重新选择' },
{ match: /quote is not in the frozen text/, message: '所选文字不在该节点冻结的正文中,请改为批注整个节点或重新选择' },
{ match: /out of range/, message: '所选范围超出该节点冻结正文的长度,请改为批注整个节点或重新选择' },
{ match: /reply must address the parent/, message: '回复只能针对原批注所在的节点,回复沿用原批注的范围' },
]
function anchorFailureMessage(raw: string): string | undefined {
if (raw.includes('textStart and textEnd must be supplied together')) return '批注范围不完整(起止位置须成对提供),请重新选择文字'
if (!raw.includes('review_anchor_invalid:')) return undefined
return ANCHOR_FAILURES.find(f => f.match.test(raw))?.message ?? '批注范围无效,请重新选择文字或改为批注整个节点'
}
export function describeRenderFailure(err: unknown): RenderFailureView {
const raw = err instanceof Error ? err.message : String(err ?? '')
if (/^40[13]:/.test(raw)) return { message: '登录已失效,请重新登录后再操作', raw }
if (/unknown or disabled command|commands disabled/.test(raw)) return { message: '当前服务未提供文档渲染能力', raw }
const anchor = anchorFailureMessage(raw)
if (anchor) return { message: anchor, raw }
const refusal = REFUSAL_MESSAGES.find(r => raw.includes(r.code))
if (refusal) return { message: refusal.message, raw }
const m = raw.match(/^\d{3}:\s*([\s\S]*)$/)
@@ -776,7 +794,13 @@ export interface ReviewReply {
resolved: boolean
orphaned: boolean
}
export interface ReviewAnnotation extends ReviewReply { replies: ReviewReply[] }
export interface ReviewAnnotation extends ReviewReply {
replies: ReviewReply[]
/** 服务端按冻结文本截取的批注范围摘录。 */
quote?: string
/** 节点仍在但范围或哈希不再匹配冻结文本:显示旧摘录,不自动迁移。 */
needsReanchor?: true
}
type RevisionKind = 'insert' | 'delete' | 'replace'
export interface ReviewRevision {
@@ -819,8 +843,10 @@ export function normalizeReviewView(raw: unknown): ReviewView {
for (const item of arr(r?.annotations)) {
const base = reply(item)
if (!base) continue
const replies = arr(obj(item)?.replies).map(reply).filter((x): x is ReviewReply => !!x)
annotations.push({ ...base, replies })
const a = obj(item)
const replies = arr(a?.replies).map(reply).filter((x): x is ReviewReply => !!x)
const quote = displayText(a?.quote, 1000)
annotations.push({ ...base, replies, ...(quote ? { quote } : {}), ...(a?.needsReanchor === true ? { needsReanchor: true as const } : {}) })
}
const revisions: ReviewRevision[] = []
for (const item of arr(r?.revisions)) {
@@ -887,9 +913,56 @@ export function reviewTargetNode(tree: DocTree | null, selectedId: string | null
/** RDM 的 nodeId 参数接受规范节点或工作区树节点;从树上审阅一律发送所选树节点 id(dn-/sec-/legacy-)。 */
interface ReviewTarget { projectId: string; submissionId: string; treeNodeId: string; operationId: string }
export function annotatePayload(input: ReviewTarget & { text: string; replyTo?: string }): Record<string, unknown> {
const { projectId, submissionId, treeNodeId, operationId } = input
return { projectId, submissionId, nodeId: treeNodeId, text: input.text.trim(), operationId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) }
/**
* 批注范围只随根批注、且只在范围属于目标节点时发送:回复继承父批注锚点,RDM 拒绝回复带范围;
* textHash 由 RDM 从冻结节点全文计算,载荷绝不含 textHash/range。
*/
export function annotatePayload(input: ReviewTarget & { text: string; replyTo?: string; range?: TextAnchor | null }): Record<string, unknown> {
const { projectId, submissionId, treeNodeId, operationId, replyTo, range } = input
const anchor = !replyTo && range && range.nodeId === treeNodeId
? { textStart: range.textStart, textEnd: range.textEnd, quote: range.quote }
: {}
return { projectId, submissionId, nodeId: treeNodeId, text: input.text.trim(), operationId, ...(replyTo ? { replyTo } : {}), ...anchor }
}
// ---- 批注范围(06 §2:nodeId + textStart + textEnd + textHash) ----
/** 码点半开区间(JS Array.from 下标,即 RDM 的 rune 偏移)与区间原文。 */
export interface TextAnchor { nodeId: string; textStart: number; textEnd: number; quote: string }
const isHighSurrogate = (c: number) => c >= 0xd800 && c <= 0xdbff
const isLowSurrogate = (c: number) => c >= 0xdc00 && c <= 0xdfff
const splitsPair = (text: string, i: number) =>
i > 0 && i < text.length && isHighSurrogate(text.charCodeAt(i - 1)) && isLowSurrogate(text.charCodeAt(i))
/** UTF-16 选区 → 码点范围:落在代理对中间的端点外扩成完整字符,首尾空白不计入;无内容返回 undefined。 */
export function codePointRange(text: string, utf16Start: number, utf16End: number): Omit<TextAnchor, 'nodeId'> | undefined {
let start = Math.max(0, Math.min(utf16Start, utf16End))
let end = Math.min(text.length, Math.max(utf16Start, utf16End))
if (splitsPair(text, start)) start--
if (splitsPair(text, end)) end++
while (start < end && /\s/.test(text[start])) start++
while (end > start && /\s/.test(text[end - 1])) end--
if (start >= end) return undefined
const quote = text.slice(start, end)
const textStart = Array.from(text.slice(0, start)).length
return { textStart, textEnd: textStart + Array.from(quote).length, quote }
}
/** 选区与某个节点正文元素的交集(UTF-16 偏移,已裁到该元素文本内)。 */
export interface SelectionHit { nodeId: string; text: string; start: number; end: number }
/** 阅读视图选区判定:null 为无选区或折叠;只落在一个节点正文内才记录范围,其余给出说明且不记录范围。 */
export function selectionAnchor(hits: SelectionHit[] | null):
| { kind: 'none' }
| { kind: 'range'; anchor: TextAnchor }
| { kind: 'multi-node' | 'outside-text'; notice: string } {
if (hits === null) return { kind: 'none' }
const touched = hits.filter(h => h.end > h.start)
if (touched.length > 1) return { kind: 'multi-node', notice: '所选文字跨越多个节点,范围批注只能选在一个节点的正文内;未记录范围,提交将批注整个节点' }
const range = touched.length === 1 ? codePointRange(touched[0].text, touched[0].start, touched[0].end) : undefined
if (!range) return { kind: 'outside-text', notice: '所选内容不在节点正文内;未记录范围,提交将批注整个节点' }
return { kind: 'range', anchor: { nodeId: touched[0].nodeId, ...range } }
}
export function revisePayload(input: ReviewTarget & { kind: RevisionKind; before?: string; after?: string }): Record<string, unknown> {
+7
View File
@@ -678,6 +678,7 @@ h2.drw-reader-heading { font-size: var(--fs-ui-xl); }
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
.drw-reader-title { background: none; border: none; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: var(--radius-xs); overflow-wrap: anywhere; }
.drw-reader-title[data-anchor-node] { cursor: text; }
.drw-anchor { color: var(--text-faint); font-size: var(--fs-ui-sm); text-decoration: none; }
.drw-anchor:hover { color: var(--accent-text); }
.drw-badges { display: inline-flex; gap: 4px; flex: none; }
@@ -747,6 +748,9 @@ h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
.drw-anno-text { color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
.drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; }
.drw-anno.needs-reanchor { border-color: var(--warn); }
.drw-anno-quote { margin: 2px 0 4px; padding: 0 8px; border-left: 2px solid var(--border-strong); color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.drw-anno-reanchor { margin-bottom: 4px; font-size: var(--fs-ui-sm); color: var(--warn-text); }
.drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
.drw-traces { display: flex; flex-direction: column; gap: 6px; }
.drw-trace { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
@@ -768,6 +772,9 @@ h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
.drw-composer-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-composer-node { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.drw-composer-reply { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-composer-scope { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-composer-quote { min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-anchor-notice { font-size: var(--fs-ui-sm); color: var(--warn-text); }
.drw-textarea { min-height: 64px; resize: vertical; font-size: var(--fs-ui-sm); }
.drw-inline-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.drw-link { padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
+167
View File
@@ -0,0 +1,167 @@
// 批注范围锚定(06 §2:nodeId + textStart + textEnd + textHash;10 §5「批注范围准确」)。
// RDM 契约:偏移是冻结节点文本上的 Unicode 码点(Go rune = JS Array.from 下标)半开区间;
// quote 与范围同发时须等于范围原文;回复不带范围;textHash/range 永不由前端发送。
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
annotatePayload, codePointRange, describeRenderFailure, normalizeReviewView, partitionAnnotations, selectionAnchor,
} from '../src/documentRenderPresentation'
import { AnnotationGroups, AnnotationScope } from '../src/components/DocumentRenderWorkspace'
describe('码点偏移(UTF-16 选区 → RDM rune 偏移)', () => {
it('ASCII:半开区间,参数顺序无关,越界裁剪', () => {
expect(codePointRange('Hello world', 6, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
expect(codePointRange('Hello world', 11, 6)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
expect(codePointRange('Hello world', 6, 99)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
})
it('首尾空白不计入,纯空白或空选区不记录范围', () => {
expect(codePointRange('Hello world', 5, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
expect(codePointRange('Hello world', 5, 6)).toBeUndefined()
expect(codePointRange('Hello world', 3, 3)).toBeUndefined()
})
it('CJK:与契约示例一致("审阅正文 Review" 中 Review 为 5..11)', () => {
expect(codePointRange('审阅正文 Review', 5, 11)).toEqual({ textStart: 5, textEnd: 11, quote: 'Review' })
expect(codePointRange('项目内容、目标及意义', 5, 7)).toEqual({ textStart: 5, textEnd: 7, quote: '目标' })
})
it('emoji 代理对:UTF-16 下标换算为码点,后续字符不错位', () => {
const text = '审阅👍正文 Review'
expect(text.indexOf('Review')).toBe(7)
expect(codePointRange(text, 7, 13)).toEqual({ textStart: 6, textEnd: 12, quote: 'Review' })
expect(Array.from(text).slice(6, 12).join('')).toBe('Review')
expect(codePointRange(text, 2, 4)).toEqual({ textStart: 2, textEnd: 3, quote: '👍' })
})
it('端点落在代理对中间时外扩为完整字符,绝不发出半个字符', () => {
const text = '审阅👍正文'
expect(codePointRange(text, 3, 5)).toEqual({ textStart: 2, textEnd: 4, quote: '👍正' })
expect(codePointRange(text, 0, 3)).toEqual({ textStart: 0, textEnd: 3, quote: '审阅👍' })
})
it('ZWJ 组合 emoji 按码点(rune)计数而非字形', () => {
const family = '👨\u200d👩\u200d👧'
const text = `a${family}b`
expect(codePointRange(text, 1, 1 + family.length)).toEqual({ textStart: 1, textEnd: 6, quote: family })
})
})
describe('阅读视图选区判定', () => {
const hit = (nodeId: string, text: string, start: number, end: number) => ({ nodeId, text, start, end })
it('无选区或折叠:不记录', () => {
expect(selectionAnchor(null)).toEqual({ kind: 'none' })
})
it('落在单个节点正文内:记录该节点的码点范围', () => {
expect(selectionAnchor([hit('dn-head', '项目内容、目标及意义', 5, 7)]))
.toEqual({ kind: 'range', anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
})
it('只擦到下一节点起点(零长度交集)仍算单节点', () => {
expect(selectionAnchor([hit('a', 'Alpha', 0, 5), hit('b', 'Beta', 0, 0)]))
.toEqual({ kind: 'range', anchor: { nodeId: 'a', textStart: 0, textEnd: 5, quote: 'Alpha' } })
})
it('跨越多个节点:说明原因,不记录范围(提交批注整个节点)', () => {
const r = selectionAnchor([hit('a', 'Alpha', 2, 5), hit('b', 'Beta', 0, 2)])
expect(r.kind).toBe('multi-node')
expect(r.kind === 'multi-node' && r.notice).toContain('跨越多个节点')
expect(r.kind === 'multi-node' && r.notice).toContain('批注整个节点')
})
it('选区不含任何节点正文(或只有空白):说明原因,不记录范围', () => {
expect(selectionAnchor([]).kind).toBe('outside-text')
expect(selectionAnchor([hit('a', 'A b', 1, 2)]).kind).toBe('outside-text')
})
})
describe('rdm.render.review.annotate 载荷', () => {
const base = { projectId: 'p', submissionId: 'sub', treeNodeId: 'dn-head', text: ' 批 ', operationId: 'op' }
const range = { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' }
it('有选区:发送 textStart/textEnd/quote,绝不发送 textHash 或 range', () => {
const p = annotatePayload({ ...base, range })
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', textStart: 5, textEnd: 7, quote: '目标' })
expect(p).not.toHaveProperty('textHash')
expect(p).not.toHaveProperty('range')
})
it('无选区:整节点批注,不带任何范围参数', () => {
for (const p of [annotatePayload(base), annotatePayload({ ...base, range: null })]) {
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op' })
}
})
it('回复:即使持有选区也不带范围(继承父批注锚点)', () => {
const p = annotatePayload({ ...base, replyTo: 'a1', range })
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', replyTo: 'a1' })
for (const k of ['textStart', 'textEnd', 'quote', 'textHash', 'range']) expect(p).not.toHaveProperty(k)
})
it('范围不属于目标节点时不发送(绝不把 A 节点的偏移挂到 B 节点)', () => {
expect(annotatePayload({ ...base, range: { ...range, nodeId: 'dn-other' } })).not.toHaveProperty('textStart')
})
})
describe('审阅视图:摘录与需重新锚定', () => {
const person = { id: 'u1', displayName: '审核人' }
const view = normalizeReviewView({
annotations: [
{ id: 'a1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '精确锚定', quote: '目标', needsReanchor: false,
range: { start: 5, end: 7, textHash: 'h' }, replies: [{ id: 'r1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '回复', quote: '目标' }] },
{ id: 'a2', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '漂移了', quote: '旧目标', needsReanchor: true, replies: [] },
{ id: 'a3', nodeId: 'pp.sig', treeNodeId: 'dn-head', author: person, createdAt: '', text: '节点级', replies: [] },
],
})
it('归一化保留 quote 与 needsReanchor;缺省不补造', () => {
expect(view.annotations[0]).toMatchObject({ quote: '目标' })
expect(view.annotations[0]).not.toHaveProperty('needsReanchor')
expect(view.annotations[1]).toMatchObject({ quote: '旧目标', needsReanchor: true })
expect(view.annotations[2]).not.toHaveProperty('quote')
})
it('批注项显示摘录;需重新锚定时有可见状态与说明', () => {
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['dn-head']))
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '项目内容、目标及意义', onSelectNode: () => {} }))
expect(html).toContain('<blockquote class="drw-anno-quote" title="目标">目标</blockquote>')
expect(html).toContain('<blockquote class="drw-anno-quote" title="旧目标">旧目标</blockquote>')
expect(html.match(/需重新锚定/g)).toHaveLength(1)
expect(html).toContain('drw-anno needs-reanchor')
expect(html).toContain('上方为旧摘录')
expect(html.match(/drw-anno-quote/g)).toHaveLength(2)
})
})
describe('撰写区的批注范围', () => {
const render = (props: Partial<Parameters<typeof AnnotationScope>[0]>) =>
renderToStaticMarkup(createElement(AnnotationScope, { anchor: null, notice: '', replying: false, onClear: () => {}, ...props }))
it('有选区:显示摘录、码点位置与改为整个节点', () => {
const html = render({ anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
expect(html).toContain('「目标」')
expect(html).toContain('第 6–7 字')
expect(html).toContain('改为整个节点')
})
it('无选区:明示整个节点', () => {
expect(render({})).toContain('整个节点(在阅读视图中选中该节点的文字')
})
it('选区无效:显示说明', () => {
expect(render({ notice: '所选文字跨越多个节点' })).toContain('role="status">所选文字跨越多个节点')
})
it('回复:沿用原批注范围,不显示选区', () => {
const html = render({ replying: true, anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
expect(html).toContain('回复沿用原批注的范围')
expect(html).not.toContain('目标')
})
})
describe('review_anchor_invalid: 转为中文可读文案(原始回执保留)', () => {
const cases: [string, string][] = [
['review_anchor_invalid: node pp.sig (image) has no frozen text a range can anchor; address a paragraph, heading, list item or table cell', '没有可按范围批注的正文'],
['review_anchor_invalid: quote does not match the frozen text of node pp.goal at 5..7', '与提交审阅时冻结的正文不一致'],
['review_anchor_invalid: quote occurs more than once in node pp.goal; supply textStart and textEnd', '出现多次'],
['review_anchor_invalid: quote is not in the frozen text of node pp.goal', '不在该节点冻结的正文中'],
['review_anchor_invalid: textStart/textEnd 5..99 out of range for node pp.goal (10 characters)', '超出该节点冻结正文的长度'],
['review_anchor_invalid: reply must address the parent\'s node pp.goal; replies inherit the parent\'s anchor', '回复只能针对原批注所在的节点'],
['textStart and textEnd must be supplied together', '起止位置须成对提供'],
['review_anchor_invalid: something new', '批注范围无效'],
]
it.each(cases)('%s', (msg, expected) => {
for (const raw of [msg, `400: ${JSON.stringify({ error: msg })}`]) {
const f = describeRenderFailure(new Error(raw))
expect(f.message).toContain(expected)
expect(f.message).not.toContain('review_anchor_invalid')
expect(f.raw).toBe(raw)
}
})
it('非范围错误不受影响', () => {
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
})
})