Anchor review annotations on the selected node text range
governance / governance (pull_request) Successful in 15s
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:
@@ -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 GENERAL = { id: env('RBA_GENERAL_PROFILE'), version: env('RBA_GENERAL_VERSION') }
|
||||||
const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}`
|
const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}`
|
||||||
const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标`
|
const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标`
|
||||||
|
const RANGE_ANNOTATION = `§11 范围批注 ${RUN}:「目标」需可量化`
|
||||||
const REVISION_AFTER = `§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)')
|
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-diff-add', { hasText: REVISION_AFTER })).toBeVisible({ timeout: 30_000 })
|
||||||
await expect(review.locator('.drw-anno-group .drw-anno-node').first()).toHaveText('项目内容、目标及意义')
|
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()
|
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 expectReadableFailures(page)
|
||||||
await shot(page, 'item5b-review-annotations')
|
await shot(page, 'item5b-review-annotations')
|
||||||
await s.close()
|
await s.close()
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ import {
|
|||||||
groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, locateNodeId, mergeDeliveries, moveSelection,
|
groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, locateNodeId, mergeDeliveries, moveSelection,
|
||||||
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, normalizeRenderTrace,
|
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, normalizeRenderTrace,
|
||||||
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope,
|
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,
|
validateReviewText, visibleNodeIds, visibleOrder, walkDocTree,
|
||||||
decisionEntries, decisionPayload, normalizeDocSubmission, currentBindings, bindableRules, normalizeCandidateRules,
|
decisionEntries, decisionPayload, normalizeDocSubmission, currentBindings, bindableRules, normalizeCandidateRules,
|
||||||
profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel, createSingleFlight,
|
profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel, createSingleFlight,
|
||||||
type BindSource, type DecisionKind, type RuleRef,
|
type BindSource, type DecisionKind, type RuleRef,
|
||||||
type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind,
|
type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind,
|
||||||
type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type RenderTrace,
|
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'
|
} from '../documentRenderPresentation'
|
||||||
|
|
||||||
// ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ----
|
// ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ----
|
||||||
@@ -170,12 +170,19 @@ function Byline({ name, at }: { name: string; at: string }) {
|
|||||||
|
|
||||||
function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: ReviewAnnotation) => void }) {
|
function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: ReviewAnnotation) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className={`drw-anno${a.resolved ? ' resolved' : ''}`}>
|
<div className={`drw-anno${a.resolved ? ' resolved' : ''}${a.needsReanchor ? ' needs-reanchor' : ''}`}>
|
||||||
<div className="drw-anno-head">
|
<div className="drw-anno-head">
|
||||||
<Byline name={a.author.displayName} at={a.createdAt} />
|
<Byline name={a.author.displayName} at={a.createdAt} />
|
||||||
|
{a.needsReanchor && <Chip tone="warn">需重新锚定</Chip>}
|
||||||
{a.resolved && <Chip tone="ok">已解决</Chip>}
|
{a.resolved && <Chip tone="ok">已解决</Chip>}
|
||||||
{onReply && <button type="button" className="drw-link" onClick={() => onReply(a)}>回复</button>}
|
{onReply && <button type="button" className="drw-link" onClick={() => onReply(a)}>回复</button>}
|
||||||
</div>
|
</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>
|
<div className="drw-anno-text">{a.text}</div>
|
||||||
{a.replies.length > 0 && (
|
{a.replies.length > 0 && (
|
||||||
<div className="drw-anno-replies">
|
<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 ref = useRef<HTMLDivElement>(null)
|
||||||
const wantFocus = useFocusSelected(ref, selectedId, revealSeq)
|
const wantFocus = useFocusSelected(ref, selectedId, revealSeq)
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -603,20 +637,31 @@ function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts,
|
|||||||
wantFocus.current = true
|
wantFocus.current = true
|
||||||
onSelect(next)
|
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 (
|
return (
|
||||||
<div ref={ref} className="drw-reader" onKeyDown={onKey}>
|
<div ref={ref} className="drw-reader" onKeyDown={onKey} onMouseUp={onMouseUp}>
|
||||||
{visibleOrder(tree, visible).map(id => {
|
{visibleOrder(tree, visible).map(id => {
|
||||||
const n = tree.nodes[id]
|
const n = tree.nodes[id]
|
||||||
const level = Math.min(n.depth + 2, 5)
|
const level = Math.min(n.depth + 2, 5)
|
||||||
const Heading = `h${level}` as 'h2' | 'h3' | 'h4' | 'h5'
|
const Heading = `h${level}` as 'h2' | 'h3' | 'h4' | 'h5'
|
||||||
const f = facts[id]
|
const f = facts[id]
|
||||||
|
const activate = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect(id)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<section key={id} id={nodeAnchorId(id)} className={`drw-reader-node${selectedId === id ? ' sel' : ''}`}>
|
<section key={id} id={nodeAnchorId(id)} className={`drw-reader-node${selectedId === id ? ' sel' : ''}`}>
|
||||||
<Heading className="drw-reader-heading">
|
<Heading className="drw-reader-heading">
|
||||||
<button type="button" className="drw-reader-title" data-node-id={id} aria-current={selectedId === id ? 'true' : undefined}
|
<span role="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)}>
|
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}
|
{n.title}
|
||||||
</button>
|
</span>
|
||||||
<a className="drw-anchor" href={`#${nodeAnchorId(id)}`} aria-label={`${n.title} 的锚点`}>¶</a>
|
<a className="drw-anchor" href={`#${nodeAnchorId(id)}`} aria-label={`${n.title} 的锚点`}>¶</a>
|
||||||
{badges(id)}
|
{badges(id)}
|
||||||
{n.state && <Chip tone={n.state === 'baselined' ? 'ok' : 'plain'}>{n.state}</Chip>}
|
{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
|
nodeTitle: string | undefined
|
||||||
replyTo: ReviewAnnotation | null
|
replyTo: ReviewAnnotation | null
|
||||||
onCancelReply: () => void
|
onCancelReply: () => void
|
||||||
|
anchor: TextAnchor | null
|
||||||
|
anchorNotice: string
|
||||||
|
onClearAnchor: () => void
|
||||||
canAnnotate: boolean
|
canAnnotate: boolean
|
||||||
canRevise: boolean
|
canRevise: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
@@ -681,6 +754,7 @@ function ReviewComposer({ nodeTitle, replyTo, onCancelReply, canAnnotate, canRev
|
|||||||
<button type="button" className="drw-link" onClick={onCancelReply}>取消回复</button>
|
<button type="button" className="drw-link" onClick={onCancelReply}>取消回复</button>
|
||||||
</div>
|
</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="写下批注…" />
|
<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 [profileKeyChoice, setProfileKeyChoice] = useState<string | null>(null)
|
||||||
const [submissionChoice, setSubmissionChoice] = useState<string | null>(null)
|
const [submissionChoice, setSubmissionChoice] = useState<string | null>(null)
|
||||||
const [replyTo, setReplyTo] = useState<ReviewAnnotation | 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 [receipt, setReceipt] = useState<Record<string, any> | null>(null)
|
||||||
|
|
||||||
const sections = useGuardedResource(`${scope}:tree`, identity, live => loadDocTree(projectId, docId, live))
|
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 selectedNode = tree && selectedId ? tree.nodes[selectedId] : undefined
|
||||||
const reviewTarget = reviewTargetNode(tree, selectedId)
|
const reviewTarget = reviewTargetNode(tree, selectedId)
|
||||||
const reviewDone = () => { review.reload(); trace.reload() }
|
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) => {
|
const annotate = (text: string, reply?: string) => {
|
||||||
if (!submissionId || !reviewTarget) return
|
if (!submissionId || !reviewTarget) return
|
||||||
|
const range = reply ? null : pick?.anchor ?? null
|
||||||
reviewOp.run('annotate', operationId => api.rdmCommand('rdm.render.review.annotate',
|
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) => {
|
const revise = (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => {
|
||||||
if (!submissionId || !reviewTarget) return
|
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>}
|
{order.length <= 1 && tree.order.length > 1 && <div className="empty-note">没有符合过滤条件的节点</div>}
|
||||||
{view === 'canvas'
|
{view === 'canvas'
|
||||||
? <StructureCanvas tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} revealSeq={revealSeq} />
|
? <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 && (
|
{selectedNode && (
|
||||||
<div className="drw-selected">
|
<div className="drw-selected">
|
||||||
<SectionLabel>当前节点</SectionLabel>
|
<SectionLabel>当前节点</SectionLabel>
|
||||||
@@ -992,6 +1092,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
|||||||
)}
|
)}
|
||||||
{(caps.annotate || caps.revise) && (
|
{(caps.annotate || caps.revise) && (
|
||||||
<ReviewComposer nodeTitle={reviewTarget?.title} replyTo={replyTo} onCancelReply={() => setReplyTo(null)}
|
<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'}
|
canAnnotate={caps.annotate} canRevise={caps.revise} busy={reviewOp.state.status === 'running'}
|
||||||
onAnnotate={annotate} onRevise={revise} />
|
onAnnotate={annotate} onRevise={revise} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -104,10 +104,28 @@ const REFUSAL_MESSAGES: { code: string; message: string }[] = [
|
|||||||
{ code: 'project source preflight', message: '审签来源尚未通过校验(来源消息已记录),请稍后重试;审签未生效' },
|
{ 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 {
|
export function describeRenderFailure(err: unknown): RenderFailureView {
|
||||||
const raw = err instanceof Error ? err.message : String(err ?? '')
|
const raw = err instanceof Error ? err.message : String(err ?? '')
|
||||||
if (/^40[13]:/.test(raw)) return { message: '登录已失效,请重新登录后再操作', raw }
|
if (/^40[13]:/.test(raw)) return { message: '登录已失效,请重新登录后再操作', raw }
|
||||||
if (/unknown or disabled command|commands disabled/.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))
|
const refusal = REFUSAL_MESSAGES.find(r => raw.includes(r.code))
|
||||||
if (refusal) return { message: refusal.message, raw }
|
if (refusal) return { message: refusal.message, raw }
|
||||||
const m = raw.match(/^\d{3}:\s*([\s\S]*)$/)
|
const m = raw.match(/^\d{3}:\s*([\s\S]*)$/)
|
||||||
@@ -776,7 +794,13 @@ export interface ReviewReply {
|
|||||||
resolved: boolean
|
resolved: boolean
|
||||||
orphaned: 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'
|
type RevisionKind = 'insert' | 'delete' | 'replace'
|
||||||
export interface ReviewRevision {
|
export interface ReviewRevision {
|
||||||
@@ -819,8 +843,10 @@ export function normalizeReviewView(raw: unknown): ReviewView {
|
|||||||
for (const item of arr(r?.annotations)) {
|
for (const item of arr(r?.annotations)) {
|
||||||
const base = reply(item)
|
const base = reply(item)
|
||||||
if (!base) continue
|
if (!base) continue
|
||||||
const replies = arr(obj(item)?.replies).map(reply).filter((x): x is ReviewReply => !!x)
|
const a = obj(item)
|
||||||
annotations.push({ ...base, replies })
|
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[] = []
|
const revisions: ReviewRevision[] = []
|
||||||
for (const item of arr(r?.revisions)) {
|
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-)。 */
|
/** RDM 的 nodeId 参数接受规范节点或工作区树节点;从树上审阅一律发送所选树节点 id(dn-/sec-/legacy-)。 */
|
||||||
interface ReviewTarget { projectId: string; submissionId: string; treeNodeId: string; operationId: string }
|
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
|
* 批注范围只随根批注、且只在范围属于目标节点时发送:回复继承父批注锚点,RDM 拒绝回复带范围;
|
||||||
return { projectId, submissionId, nodeId: treeNodeId, text: input.text.trim(), operationId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) }
|
* 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> {
|
export function revisePayload(input: ReviewTarget & { kind: RevisionKind; before?: string; after?: string }): Record<string, unknown> {
|
||||||
|
|||||||
@@ -678,6 +678,7 @@ h2.drw-reader-heading { font-size: var(--fs-ui-xl); }
|
|||||||
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
|
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
|
||||||
h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
|
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 { 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 { color: var(--text-faint); font-size: var(--fs-ui-sm); text-decoration: none; }
|
||||||
.drw-anchor:hover { color: var(--accent-text); }
|
.drw-anchor:hover { color: var(--accent-text); }
|
||||||
.drw-badges { display: inline-flex; gap: 4px; flex: none; }
|
.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-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-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-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-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
|
||||||
.drw-traces { display: flex; flex-direction: column; gap: 6px; }
|
.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); }
|
.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-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-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-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-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-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; }
|
.drw-link { padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||||
|
|||||||
@@ -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('当前编制人不是你')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user