mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
审批质询(前端):划选原文浮出的「质询」挂在报告文档里,画布审批浮层里点它不再把浮层收掉
- 原来浮出的按钮挂到页面末尾(fixed)。画布节点上的审批浮层(canvas/NodeCard)在 document 上按捕获阶段听 pointerdown,按在浮层外面就收起:一点浮出的「质询」,浮层连同报告一起收掉,划选原文问不成 - 改成挂在文档里一个高度为 0 的定位锚点下面(absolute,坐标按锚点算),按下的位置就在审批卡里;挂在文档里会被限高滚动的报告框裁切,所以按可见范围摆:视口和每一层会裁切的祖先取交集,下方放不下放到选区上方,左右收进文档宽度(不把报告框撑出横向滚动),选区整个滚出可见范围时不留按钮 - check-inquiry 加一段:模拟浮层「按在外面就收起」的监听,点浮出的「质询」不算外面;报告框压矮、一句的第一行贴着框的底边时划选,按钮看得见(不被框裁掉)。两项都先确认在旧写法 / 去掉上下翻转时变红
This commit is contained in:
@@ -495,7 +495,8 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
|
||||
)
|
||||
|
||||
// 审批质询:划选一段原文,浮出「质询」,问的是选中的这段(带上它起头的那一句)
|
||||
const [pick, dropPick] = useSelectionPick(rootRef, ask)
|
||||
const pickAnchor = useRef<HTMLDivElement>(null)
|
||||
const [pick, dropPick] = useSelectionPick(rootRef, pickAnchor, ask)
|
||||
const askPick = () => {
|
||||
if (!pick || !inquiry || !runId || !report) return
|
||||
// 浮出的按钮点完就没了:Esc 之后焦点回到那一句的「质询」
|
||||
@@ -511,6 +512,22 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
|
||||
|
||||
return (
|
||||
<div ref={rootRef} data-evidence-doc="" className="min-w-0">
|
||||
{ask && (
|
||||
// 划选原文浮出的「质询」挂在文档里(高度为 0 的定位锚点下面,按锚点算坐标),不挂到页面末尾:画布上的审批浮层
|
||||
// 按下在它外面就收起,挂在外面的按钮一按,浮层连同报告一起收掉。也因此会被报告框裁切,位置按可见范围摆(见
|
||||
// useSelectionPick)
|
||||
<div ref={pickAnchor} className="relative" data-inquiry-pick-anchor="">
|
||||
{pick && (
|
||||
// 按下时不抢焦点、不清掉选区:点下去才读选区
|
||||
<button type="button" className="btn btn-xs absolute z-20 whitespace-nowrap shadow-elev-3" data-inquiry-selection=""
|
||||
style={{ left: pick.left, top: pick.top }} aria-label={INQUIRY_TEXT.askSelection}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => { e.stopPropagation(); askPick() }}>
|
||||
<MessageCircleQuestion size={11} aria-hidden /> {INQUIRY_TEXT.ask}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p id={summaryId} className="sr-only" data-evidence-summary="">{summary}</p>
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true">{announce}</div>
|
||||
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && (
|
||||
@@ -555,32 +572,46 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
|
||||
)}
|
||||
{mode === 'dock' && panelNode && dock && createPortal(panelNode, dock)}
|
||||
{mode !== 'inline' && mode !== 'dock' && panelNode && createPortal(panelNode, document.body)}
|
||||
{pick && createPortal(
|
||||
// 按下时不抢焦点、不清掉选区:点下去才读选区
|
||||
<button type="button" className="btn btn-xs fixed z-[70] shadow-elev-3" data-inquiry-selection=""
|
||||
style={{ left: pick.left, top: pick.top }} aria-label={INQUIRY_TEXT.askSelection}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => { e.stopPropagation(); askPick() }}>
|
||||
<MessageCircleQuestion size={11} aria-hidden /> {INQUIRY_TEXT.ask}
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** 划选的一段原文:要问的字、它起头的那一句、浮出按钮的位置(视口坐标) */
|
||||
/** 划选的一段原文:要问的字、它起头的那一句、浮出按钮的位置(相对定位锚点) */
|
||||
interface SelectionPick { quote: string; unit: string | null; left: number; top: number }
|
||||
/** 浮出按钮的大致尺寸:放在选区末尾下方,贴边时收进视口,下方放不下时放到选区上方 */
|
||||
/** 浮出按钮的大致尺寸(宁大勿小):放在选区末尾下方,贴边时收进可见范围,下方放不下时放到选区上方 */
|
||||
const PICK_W = 64
|
||||
const PICK_H = 26
|
||||
/** 按钮和选区、和可见范围边上留的空 */
|
||||
const PICK_GAP = 6
|
||||
|
||||
/**
|
||||
* 报告正文里划选了一段原文:返回要问的字和浮出按钮的位置,选区收起、移出正文时为 null。只认报告正文里的字——栏内展开
|
||||
* 的证据面板也在这个容器里,那里的字不算。要问的字按选区克隆出来算,去掉句末徽标、「无证据」小标签、读屏文字和
|
||||
* 「质询」本身(它们在 DOM 里,却不是报告的原文)
|
||||
* 一个元素眼下看得见的范围(视口坐标):视口,再和每一层会裁切内容的祖先(overflow 不是 visible 的,比如审批卡里
|
||||
* 限高滚动的报告框)取交集。浮出的按钮挂在文档里,超出这个范围的部分会被裁掉
|
||||
*/
|
||||
function useSelectionPick(root: RefObject<HTMLElement | null>, enabled: boolean): [SelectionPick | null, () => void] {
|
||||
function visibleBox(el: Element): { top: number; bottom: number; left: number; right: number } {
|
||||
const box = { top: 0, left: 0, bottom: window.innerHeight, right: window.innerWidth }
|
||||
for (let p: Element | null = el; p && p !== document.documentElement; p = p.parentElement) {
|
||||
const style = getComputedStyle(p)
|
||||
if (style.overflowX === 'visible' && style.overflowY === 'visible') continue
|
||||
const r = p.getBoundingClientRect()
|
||||
box.top = Math.max(box.top, r.top)
|
||||
box.left = Math.max(box.left, r.left)
|
||||
box.bottom = Math.min(box.bottom, r.bottom)
|
||||
box.right = Math.min(box.right, r.right)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告正文里划选了一段原文:返回要问的字和浮出按钮的位置,选区收起、移出正文、滚出可见范围时为 null。只认报告正文
|
||||
* 里的字——栏内展开的证据面板也在这个容器里,那里的字不算。要问的字按选区克隆出来算,去掉句末徽标、「无证据」小标签、
|
||||
* 读屏文字和「质询」本身(它们在 DOM 里,却不是报告的原文)。
|
||||
*
|
||||
* 位置相对 anchor(文档里高度为 0 的定位锚点)算:按钮挂在文档里,会被限高滚动的报告框裁切,所以按可见范围摆——
|
||||
* 默认在选区末尾下方,下方放不下放到选区上方,左右收进文档的宽度(不把报告框撑出横向滚动)和可见范围
|
||||
*/
|
||||
function useSelectionPick(root: RefObject<HTMLElement | null>, anchor: RefObject<HTMLElement | null>,
|
||||
enabled: boolean): [SelectionPick | null, () => void] {
|
||||
const [pick, setPick] = useState<SelectionPick | null>(null)
|
||||
useEffect(() => {
|
||||
if (!enabled) { setPick(null); return }
|
||||
@@ -588,8 +619,9 @@ function useSelectionPick(root: RefObject<HTMLElement | null>, enabled: boolean)
|
||||
const read = () => {
|
||||
frame = 0
|
||||
const el = root.current
|
||||
const origin = anchor.current
|
||||
const sel = document.getSelection()
|
||||
if (!el || !sel || sel.isCollapsed || !sel.rangeCount) { setPick(null); return }
|
||||
if (!el || !origin || !sel || sel.isCollapsed || !sel.rangeCount) { setPick(null); return }
|
||||
const range = sel.getRangeAt(0)
|
||||
const at = (n: Node) => (n instanceof Element ? n : n.parentElement)
|
||||
const inside = (x: Element | null) => !!x && el.contains(x) && !!x.closest('[data-block]') && !x.closest('[data-evidence-panel]')
|
||||
@@ -602,17 +634,27 @@ function useSelectionPick(root: RefObject<HTMLElement | null>, enabled: boolean)
|
||||
const rects = range.getClientRects()
|
||||
const last = rects[rects.length - 1] ?? range.getBoundingClientRect()
|
||||
const first = rects[0] ?? last
|
||||
const below = last.bottom + 6 + PICK_H <= window.innerHeight
|
||||
const view = visibleBox(el)
|
||||
// 选区整个滚出了可见范围:按钮也不留(滚回来再量出来)
|
||||
if (last.bottom <= view.top || first.top >= view.bottom) { setPick(null); return }
|
||||
const doc = el.getBoundingClientRect()
|
||||
const lo = Math.max(doc.left, view.left)
|
||||
const hi = Math.min(doc.right, view.right) - PICK_W
|
||||
const x = Math.max(lo, Math.min(last.right - PICK_W / 2, hi))
|
||||
const fitsBelow = last.bottom + PICK_GAP + PICK_H <= view.bottom
|
||||
const fitsAbove = first.top - PICK_GAP - PICK_H >= view.top
|
||||
const y = fitsBelow || !fitsAbove ? Math.min(last.bottom + PICK_GAP, Math.max(view.top, view.bottom - PICK_H))
|
||||
: first.top - PICK_GAP - PICK_H
|
||||
const o = origin.getBoundingClientRect()
|
||||
const next: SelectionPick = {
|
||||
quote, unit: start?.closest('[data-ev-unit]')?.getAttribute('data-ev-unit') ?? null,
|
||||
left: Math.max(8, Math.min(last.right - PICK_W / 2, window.innerWidth - PICK_W - 8)),
|
||||
top: below ? last.bottom + 6 : Math.max(8, first.top - PICK_H - 6),
|
||||
left: Math.round(x - o.left), top: Math.round(y - o.top),
|
||||
}
|
||||
// 滚动时每一帧都会量一次:没变就不换对象,整份报告不跟着重画
|
||||
setPick((cur) => (cur && cur.quote === next.quote && cur.unit === next.unit && cur.left === next.left && cur.top === next.top
|
||||
? cur : next))
|
||||
}
|
||||
// 选区一变、页面一滚(报告在可滚动的卡片里)都重新量:按钮跟着选区走
|
||||
// 选区一变、页面或报告框一滚、窗口一变都重新量:可见范围变了,按钮放上方还是下方跟着变
|
||||
const schedule = () => { if (!frame) frame = requestAnimationFrame(read) }
|
||||
document.addEventListener('selectionchange', schedule)
|
||||
window.addEventListener('scroll', schedule, true)
|
||||
@@ -623,7 +665,7 @@ function useSelectionPick(root: RefObject<HTMLElement | null>, enabled: boolean)
|
||||
window.removeEventListener('scroll', schedule, true)
|
||||
window.removeEventListener('resize', schedule)
|
||||
}
|
||||
}, [root, enabled])
|
||||
}, [root, anchor, enabled])
|
||||
return [pick, useCallback(() => setPick(null), [])]
|
||||
}
|
||||
|
||||
|
||||
@@ -484,6 +484,76 @@ await section('划选原文:浮出「质询」,带上原文', async () => {
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('划选原文:浮出的「质询」算在审批卡里,贴着报告框底边时也看得见', async () => {
|
||||
const { ctx, page, hits, errors } = await open('inquiry-approval')
|
||||
await expand(page)
|
||||
// 画布上的审批浮层(canvas/NodeCard)在 document 上按捕获阶段听 pointerdown,按在浮层外面就收起。浮出的「质询」
|
||||
// 要是挂在页面末尾,点它等于点外面:浮层连同报告一起收掉,问不成
|
||||
await page.evaluate(() => {
|
||||
window.__outside = []
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
const card = document.querySelector('[data-approval]')
|
||||
if (!card?.contains(e.target)) window.__outside.push(String(e.target?.outerHTML ?? e.target).slice(0, 80))
|
||||
}, true)
|
||||
})
|
||||
await select(page, 'u4', '枫岭区销售额最高')
|
||||
await page.locator('[data-inquiry-selection]').waitFor()
|
||||
await page.locator('[data-inquiry-selection]').click()
|
||||
await composer(page).waitFor()
|
||||
const outside = await page.evaluate(() => window.__outside)
|
||||
check('点浮出的「质询」:按下的位置在审批卡里,「点外面就收起」的浮层不会因此收起', outside.length === 0, outside.join(' | '))
|
||||
check('……照样打开提问框,带上原文', (await text(composer(page).locator('[data-inquiry-target]'))) === '枫岭区销售额最高')
|
||||
await page.keyboard.press('Escape')
|
||||
// 报告框压矮、滚到某一句的第一行贴着框的底边(取压矮之后落在框外、滚得过去的第一句),在那一行开头划选几个字:
|
||||
// 浮出的按钮放在选区下方会被框裁掉,得换到上方
|
||||
const edge = await page.evaluate(() => {
|
||||
const box = document.querySelector('[data-approval-evidence-doc]')
|
||||
if (!box) return null
|
||||
box.style.maxHeight = '150px'
|
||||
for (const unit of box.querySelectorAll('[data-ev-unit]')) {
|
||||
const b = box.getBoundingClientRect()
|
||||
const line = unit.getClientRects()[0]
|
||||
const need = line.bottom - (b.bottom - 6)
|
||||
if (need <= 0 || need > box.scrollHeight - box.clientHeight - box.scrollTop) continue
|
||||
box.scrollTop += need
|
||||
const walker = document.createTreeWalker(unit, NodeFilter.SHOW_TEXT)
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
const t = n.data.trim()
|
||||
if (t.length < 4 || n.parentElement?.closest('[data-inquiry-ask], .sr-only')) continue
|
||||
const at = n.data.indexOf(t)
|
||||
const range = document.createRange()
|
||||
range.setStart(n, at)
|
||||
range.setEnd(n, at + 4)
|
||||
getSelection().removeAllRanges()
|
||||
getSelection().addRange(range)
|
||||
const r = range.getBoundingClientRect()
|
||||
const after = box.getBoundingClientRect()
|
||||
return { unit: unit.getAttribute('data-ev-unit'), words: t.slice(0, 4), gap: Math.round(after.bottom - r.bottom) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
check('报告框压矮后可以滚动:一句的第一行贴着框的底边,在那一行开头划选', !!edge && edge.gap >= 0 && edge.gap < 20, JSON.stringify(edge))
|
||||
await page.locator('[data-inquiry-selection]').waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
const vis = await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-inquiry-selection]')
|
||||
const r = btn.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
const box = document.querySelector('[data-approval-evidence-doc]').getBoundingClientRect()
|
||||
return { shown: !!hit && btn.contains(hit), w: Math.round(r.width), inBox: r.top >= box.top - 1 && r.bottom <= box.bottom + 1 }
|
||||
})
|
||||
check('浮出的「质询」看得见:不被报告框裁掉', vis.shown && vis.w > 20, JSON.stringify(vis))
|
||||
await page.locator('[data-inquiry-selection]').click()
|
||||
await composer(page).waitFor()
|
||||
check('点它照样打开提问框,带上划选的字', (await text(composer(page).locator('[data-inquiry-target]'))) === edge?.words,
|
||||
`${await text(composer(page).locator('[data-inquiry-target]'))} / ${edge?.words}`)
|
||||
await shot(page, 'selection-edge-dark')
|
||||
check('没有发出任何写请求', hits.writes.length === 0 && hits.asks.length === 0, hits.writes.join('、'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('就整份报告提问;recorded 为假时醒目说明', async () => {
|
||||
const { ctx, page, hits, state, errors } = await open('inquiry-approval')
|
||||
state.reply = (body) => ({ status: 200, json: answerFor(body, {
|
||||
|
||||
Reference in New Issue
Block a user