From c5d81f41e36c142db7fa6d566eca57af99622037 Mon Sep 17 00:00:00 2001 From: PerryLink Date: Mon, 21 Sep 2026 12:51:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=89=B9=E6=B3=A8=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E4=B8=8E=E7=BC=96=E5=8F=B7=E4=B8=8D=E5=86=8D=E5=8E=8B=E4=BD=8F?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=A1=B6=E6=A0=8F=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 ++ client.js | 55 ++++++++++++++++++---- test/layout-overlay.test.mjs | 89 ++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 904dd27..007142f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +### 修复 +- 会话顶栏不再被批注高亮与编号胶囊压住:标记层在原有输入框挖洞之外,再挖掉「会话列内、`[data-conversation-scroll]` 上沿之上」的顶栏横带,编号胶囊的顶部下限同步抬到该上沿以下;侧边栏文件批注不受影响(#62,延续 #44 / #50 的同一层级问题)。 + ## [1.4.11-preview.1] - 2026-09-15 - 更新 preview 预发布验收宿主至 DSH 0.1.6-alpha.1;发布任务验证同一安装包后发布至 npm preview。 diff --git a/client.js b/client.js index ab37786..b5b3f2a 100644 --- a/client.js +++ b/client.js @@ -1411,16 +1411,50 @@ window.__ModuleLoader__.load({ return parts.join('|') } + // 标记层是 body 级 fixed 图层(z-index 900/940),会话顶栏是文档流里的普通元素, + // 因此被批注的原文一旦滚到顶栏之下,高亮矩形仍按视口坐标绘制、直接压在顶栏上(#62)。 + // 修法与 #50 的输入框挖洞同源:再挖掉「会话列内、滚动容器上沿之上」的那条横带。 + // 只挖会话列(scrollBody 的 left/right)而不是整幅宽度,否则会把侧边栏文件预览 + // 的批注一并裁掉(#57 的侧边栏批注与顶栏横带在 x 轴上重叠)。 + function markerViewport() { + var view = document.querySelector('[data-conversation-scroll]') + var r = view !== null ? view.getBoundingClientRect() : null + return r !== null && r.width > 0 && r.top > 0 ? r : null + } + + function markerClipPath(view, composer) { + var holes = [] + if (view !== null) holes.push([view.left, 0, view.right, view.top]) + if (composer !== null && composer.width > 0 && composer.height > 0) { + holes.push([composer.left, composer.top, composer.right, composer.bottom]) + } + if (holes.length === 0) return 'none' + var parts = ['polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0'] + for (var i = 0; i < holes.length; i++) { + var h = holes[i] + parts.push( + h[0] + 'px ' + h[1] + 'px', h[2] + 'px ' + h[1] + 'px', + h[2] + 'px ' + h[3] + 'px', h[0] + 'px ' + h[3] + 'px', + h[0] + 'px ' + h[1] + 'px', + ) + } + return parts.join(', ') + ')' + } + + // 编号胶囊同样要给顶栏让位:挖洞会把落在横带里的胶囊整块裁掉, + // 所以 chipTop 的下限从「视口上沿 4px」抬到「滚动容器上沿 + 4px」。 + function markerChipFloor() { + var view = markerViewport() + return view !== null ? Math.round(view.top) + 4 : 4 + } + function renderMarkers() { - // 对整个标记层挖去输入框区域;滚动、尺寸变化时即使原文没动也要刷新。 + // 对整个标记层挖去顶栏横带与输入框区域;滚动、尺寸变化时即使原文没动也要刷新。 var composer = document.querySelector('[data-composer-card]') - var r = composer !== null ? composer.getBoundingClientRect() : null - overlay.style.clipPath = r !== null && r.width > 0 && r.height > 0 - ? 'polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ' - + r.left + 'px ' + r.top + 'px, ' + r.right + 'px ' + r.top + 'px, ' - + r.right + 'px ' + r.bottom + 'px, ' + r.left + 'px ' + r.bottom + 'px, ' - + r.left + 'px ' + r.top + 'px)' - : 'none' + overlay.style.clipPath = markerClipPath( + markerViewport(), + composer !== null ? composer.getBoundingClientRect() : null, + ) var sig = markersSignature() if (sig !== markersSig) { markersSig = sig @@ -1459,9 +1493,10 @@ window.__ModuleLoader__.load({ var chip = document.createElement('div') chip.className = 'dsh-ann-num' chip.textContent = String(i + 1) + var chipFloor = markerChipFloor() var chipTop = anchor.top - 20 - if (chipTop < 4) chipTop = Math.min(anchor.top + 2, window.innerHeight - 22) - if (chipTop < 4) chipTop = 4 + if (chipTop < chipFloor) chipTop = Math.min(anchor.top + 2, window.innerHeight - 22) + if (chipTop < chipFloor) chipTop = chipFloor var chipLeft = Math.max(4, Math.min(anchor.left - 4, window.innerWidth - 24)) var tries = 0 while (tries < 12) { diff --git a/test/layout-overlay.test.mjs b/test/layout-overlay.test.mjs index cf23e39..01c1777 100644 --- a/test/layout-overlay.test.mjs +++ b/test/layout-overlay.test.mjs @@ -25,3 +25,92 @@ test('工具条优先选区下方,底部空间不足时放在上方(PR #48 assert.equal(place({ left: 200, width: 300, top: 750, bottom: 770 }, 40).top, 702) assert.ok(place({ left: -100, width: 300, top: 0, bottom: 800 }, 40).top >= 8) }) + +// Source slice for one `function name(...) { ... }` by brace matching. The +// extracted functions hold no braces inside strings or comments, so a plain +// depth scan is enough and keeps the eval idiom of the placeAbove case above. +function tryExtractFunction (name) { + const start = source.indexOf(`function ${name}(`) + if (start < 0) return null + let depth = 0 + for (let i = source.indexOf('{', start); i < source.length; i++) { + if (source[i] === '{') depth++ + else if (source[i] === '}') { + depth-- + if (depth === 0) return source.slice(start, i + 1) + } + } + throw new Error(`unbalanced braces in ${name}`) +} + +// [data-conversation-scroll] carries the conversation column: the session +// header sits above its top edge. The hole must cover that band only, never +// the full viewport width — the sidebar file previews of #57 live outside the +// conversation column and must stay paintable. +const VIEW = { left: 12, top: 76, right: 912, bottom: 800, width: 900, height: 724 } +const COMPOSER = { left: 120, top: 600, right: 820, bottom: 700, width: 700, height: 100 } + +// Drive the real renderMarkers with stubbed DOM so the assertion is about the +// clipPath the plugin actually installs, not about which helpers exist. +// Helpers are extracted only when present: on the pre-#62 client.js they are +// absent and the inline clipPath still has to satisfy the same expectation. +function runRenderMarkers (viewRect, composerRect) { + const overlay = { style: {}, textContent: '' } + const document = { + querySelector: (selector) => { + const rect = selector === '[data-conversation-scroll]' ? viewRect : composerRect + return rect === null ? null : { getBoundingClientRect: () => rect } + }, + } + const prelude = ['markerClipPath', 'markerViewport'] + .map(tryExtractFunction).filter((text) => text !== null).join('\n') + const run = Function( + 'document', 'window', 'overlay', 'markersSignature', 'markersSig', 'buildMarkers', + `${prelude}\n${tryExtractFunction('renderMarkers')}\nreturn renderMarkers`, + ) + run(document, { innerWidth: 1000, innerHeight: 800 }, overlay, () => 'same', 'same', () => {})() + return overlay.style.clipPath +} + +test('renderMarkers 的 clipPath 挖掉会话顶栏横带,高亮不再压住顶栏(issue #62)', () => { + const clipPath = runRenderMarkers(VIEW, COMPOSER) + assert.notEqual(clipPath, 'none') + // header hole: the conversation column from the viewport top down to the scroll body + assert.ok( + clipPath.includes('12px 0px, 912px 0px, 912px 76px, 12px 76px, 12px 0px'), + `header hole missing from ${clipPath}`, + ) + // the hole must not span the whole width: sidebar file markers (#57) stay visible + assert.ok(!clipPath.includes(', 0px 0px, 100% 0px'), 'header hole must be bounded to the conversation column') +}) + +test('输入框挖洞(issue #50)保持原样,两个洞互不影响', () => { + const clipPath = runRenderMarkers(VIEW, COMPOSER) + assert.ok( + clipPath.includes('120px 600px, 820px 600px, 820px 700px, 120px 700px, 120px 600px'), + `composer hole missing from ${clipPath}`, + ) +}) + +test('没有会话滚动容器时,标记层退回只挖输入框(不依赖新宿主钩子)', () => { + assert.equal( + runRenderMarkers(null, COMPOSER), + 'polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ' + + '120px 600px, 820px 600px, 820px 700px, 120px 700px, 120px 600px)', + ) + assert.equal(runRenderMarkers(null, null), 'none') +}) + +test('编号胶囊的顶部下限跟着会话滚动容器上沿抬高(issue #62)', () => { + const build = (viewRect) => Function( + 'document', + 'window', + `var markerViewport = ${tryExtractFunction('markerViewport')};` + + `return (${tryExtractFunction('markerChipFloor')})`, + )( + { querySelector: () => (viewRect === null ? null : { getBoundingClientRect: () => viewRect }) }, + { innerHeight: 800 }, + ) + assert.equal(build(VIEW)(), 80) + assert.equal(build(null)(), 4) +})