@@ -53,7 +53,9 @@ export class DomSelectionProvider implements SelectionProvider {
5353 const visual = this . collectVisualSpansInBbox ( target , range )
5454 if ( visual ) {
5555 const partial = this . spansMultiplePdfPages ( range )
56- return { text : visual . text , rects : visual . rects , range, partial }
56+ const sourcePath = this . readPdfViewerFilePath ( target )
57+ const sourceMeta = sourcePath ? { kind : "pdf-office" , path : sourcePath } : undefined
58+ return { text : visual . text , rects : visual . rects , range, partial, sourceMeta }
5759 }
5860 // 视觉算法 fallback(罕见 — 拿不到 bbox 或无 spans)→ 走 native
5961 }
@@ -85,18 +87,63 @@ export class DomSelectionProvider implements SelectionProvider {
8587 const pdfViewer = target . closest ( '[data-slot="pdf-viewer"]' )
8688 if ( ! pdfViewer ) return null
8789
88- // native selection 的视觉边界(union of all client rects)
90+ // FORK: 优先用 Selection.anchor/focus 视觉坐标定边界(user 真实 mousedown/mouseup 点),
91+ // 而非 range.getClientRects() bbox。
92+ // [feat: office-选中加聊天] 2026-05-25 user QA #N+2 反馈
93+ //
94+ // 背景:PDF.js textLayer span 的 DOM 顺序 ≠ 视觉顺序(复杂 PDF 中标题/段落乱序很常见)。
95+ // range.getClientRects() 沿 DOM 顺序遍历返回 rects,会把"DOM 在中间但视觉上跨页"的 spans
96+ // 全算进 bbox,导致 user 仅拖几行却选中"几乎整页"(实测 user 从"单一"拖到"Claude"4 行,
97+ // bbox 扩到包含 title + 4 个 section)。
98+ // 解法:用 sel.anchorNode/focusNode 算出真实拖拽起止 2 点 → 用这 2 点的视觉坐标围 bbox。
99+ // anchor/focus 是 user 实际 mousedown/mouseup 的 caret 位置,不会跨视觉边界。
100+ const sel = typeof window !== "undefined" ? window . getSelection ( ) : null
101+ let anchorRect : DOMRect | null = null
102+ let focusRect : DOMRect | null = null
103+ if ( sel && sel . anchorNode && sel . focusNode ) {
104+ try {
105+ const ar = document . createRange ( )
106+ ar . setStart ( sel . anchorNode , sel . anchorOffset )
107+ ar . setEnd ( sel . anchorNode , sel . anchorOffset )
108+ anchorRect = ar . getBoundingClientRect ( )
109+ const fr = document . createRange ( )
110+ fr . setStart ( sel . focusNode , sel . focusOffset )
111+ fr . setEnd ( sel . focusNode , sel . focusOffset )
112+ focusRect = fr . getBoundingClientRect ( )
113+ } catch {
114+ // 罕见:detached node / cross-document — 落回 nativeRects bbox
115+ }
116+ }
117+
89118 const nativeRects = Array . from ( range . getClientRects ( ) ) . filter (
90119 ( r ) => r . width > 0 && r . height > 0 ,
91120 )
92- if ( nativeRects . length === 0 ) return null
93- let bboxTop = Infinity
94- let bboxBottom = - Infinity
95- for ( const r of nativeRects ) {
96- if ( r . top < bboxTop ) bboxTop = r . top
97- if ( r . bottom > bboxBottom ) bboxBottom = r . bottom
121+
122+ let bboxTop : number
123+ let bboxBottom : number
124+ if (
125+ anchorRect &&
126+ focusRect &&
127+ Number . isFinite ( anchorRect . top ) &&
128+ Number . isFinite ( focusRect . top ) &&
129+ anchorRect . height > 0 &&
130+ focusRect . height > 0
131+ ) {
132+ const ay = anchorRect . top + anchorRect . height / 2
133+ const fy = focusRect . top + focusRect . height / 2
134+ // 加 2px 容差吃掉 caret 微抖
135+ bboxTop = Math . min ( ay , fy ) - 2
136+ bboxBottom = Math . max ( ay , fy ) + 2
137+ } else {
138+ if ( nativeRects . length === 0 ) return null
139+ bboxTop = Infinity
140+ bboxBottom = - Infinity
141+ for ( const r of nativeRects ) {
142+ if ( r . top < bboxTop ) bboxTop = r . top
143+ if ( r . bottom > bboxBottom ) bboxBottom = r . bottom
144+ }
145+ if ( ! Number . isFinite ( bboxTop ) || ! Number . isFinite ( bboxBottom ) ) return null
98146 }
99- if ( ! Number . isFinite ( bboxTop ) || ! Number . isFinite ( bboxBottom ) ) return null
100147
101148 // 找 pdf-viewer 子树内所有 textLayer spans,过滤中心 y 在 bbox 内
102149 // 注:**不过滤 whitespace-only spans** — pdf.js textLayer 用 " " span 表词间空格
@@ -134,14 +181,46 @@ export class DomSelectionProvider implements SelectionProvider {
134181 }
135182 }
136183
137- // 拿 native 选区的真实 x 边界,裁顶/底行(防止包含 user 没选到的 x 范围)
138- const sortedNative = [ ...nativeRects ] . sort ( ( a , b ) => a . top - b . top || a . left - b . left )
139- const firstNative = sortedNative [ 0 ]
140- const lastNative = sortedNative [ sortedNative . length - 1 ]
141- const selStartX = firstNative . left
142- const selEndX = lastNative . right
143- const selStartCy = firstNative . top + firstNative . height / 2
144- const selEndCy = lastNative . top + lastNative . height / 2
184+ // 拿 user 拖拽真实 x 边界 — 同样优先 anchor/focus,fallback 到 nativeRects
185+ let selStartX : number
186+ let selEndX : number
187+ let selStartCy : number
188+ let selEndCy : number
189+ if (
190+ anchorRect &&
191+ focusRect &&
192+ Number . isFinite ( anchorRect . top ) &&
193+ Number . isFinite ( focusRect . top ) &&
194+ anchorRect . height > 0 &&
195+ focusRect . height > 0
196+ ) {
197+ const ax = anchorRect . left
198+ const ay = anchorRect . top + anchorRect . height / 2
199+ const fx = focusRect . left
200+ const fy = focusRect . top + focusRect . height / 2
201+ // 把 anchor/focus 按视觉位置归到 start/end(top→bottom,同行 left→right)
202+ const aFirst =
203+ Math . abs ( ay - fy ) <= 5 ? ax <= fx : ay < fy
204+ if ( aFirst ) {
205+ selStartX = ax
206+ selStartCy = ay
207+ selEndX = fx
208+ selEndCy = fy
209+ } else {
210+ selStartX = fx
211+ selStartCy = fy
212+ selEndX = ax
213+ selEndCy = ay
214+ }
215+ } else {
216+ const sortedNative = [ ...nativeRects ] . sort ( ( a , b ) => a . top - b . top || a . left - b . left )
217+ const firstNative = sortedNative [ 0 ]
218+ const lastNative = sortedNative [ sortedNative . length - 1 ]
219+ selStartX = firstNative . left
220+ selEndX = lastNative . right
221+ selStartCy = firstNative . top + firstNative . height / 2
222+ selEndCy = lastNative . top + lastNative . height / 2
223+ }
145224
146225 // **关键修法**:每行合并成一个 rect 从 minLeft → maxRight 消除字间/词间所有 gaps
147226 // (user 2026-05-25 反馈每个字独立 rect 间有 1-2px 字间距和 word spacing 显白,
@@ -223,6 +302,17 @@ export class DomSelectionProvider implements SelectionProvider {
223302 return count > 1
224303 }
225304
305+ /**
306+ * 读 pdf-viewer wrapper 上的 `data-file-path`(file-tabs.tsx 设置)。
307+ * 用于把 PDF/office 选区送回 chat 时附 source path → 卡片显示文件名 + LLM 看到出处。
308+ * 找不到返回空字符串(罕见 — wrapper 缺 path 属性,fallback 走纯 text 路径)。
309+ */
310+ private readPdfViewerFilePath ( target : Element ) : string {
311+ const pdfViewer = target . closest < HTMLElement > ( '[data-slot="pdf-viewer"]' )
312+ if ( ! pdfViewer ) return ""
313+ return pdfViewer . dataset . filePath ?? ""
314+ }
315+
226316 clear ( ) : void {
227317 if ( typeof window === "undefined" ) return
228318 try {
0 commit comments