diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index 2afd561d..23c550ad 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -495,7 +495,8 @@ export const EvidenceDoc = forwardRef(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 + {ask && ( + // 划选原文浮出的「质询」挂在文档里(高度为 0 的定位锚点下面,按锚点算坐标),不挂到页面末尾:画布上的审批浮层 + // 按下在它外面就收起,挂在外面的按钮一按,浮层连同报告一起收掉。也因此会被报告框裁切,位置按可见范围摆(见 + // useSelectionPick) +
+ {pick && ( + // 按下时不抢焦点、不清掉选区:点下去才读选区 + + )} +
+ )}

{summary}

{announce}
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && ( @@ -555,32 +572,46 @@ export const EvidenceDoc = forwardRef e.preventDefault()} - onClick={(e) => { e.stopPropagation(); askPick() }}> - {INQUIRY_TEXT.ask} - , - document.body, - )} ) }) -/** 划选的一段原文:要问的字、它起头的那一句、浮出按钮的位置(视口坐标) */ +/** 划选的一段原文:要问的字、它起头的那一句、浮出按钮的位置(相对定位锚点) */ 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, 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, anchor: RefObject, + enabled: boolean): [SelectionPick | null, () => void] { const [pick, setPick] = useState(null) useEffect(() => { if (!enabled) { setPick(null); return } @@ -588,8 +619,9 @@ function useSelectionPick(root: RefObject, 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, 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, enabled: boolean) window.removeEventListener('scroll', schedule, true) window.removeEventListener('resize', schedule) } - }, [root, enabled]) + }, [root, anchor, enabled]) return [pick, useCallback(() => setPick(null), [])] } diff --git a/scripts/check-inquiry.mjs b/scripts/check-inquiry.mjs index 92c9760f..b88fd7ae 100644 --- a/scripts/check-inquiry.mjs +++ b/scripts/check-inquiry.mjs @@ -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, {