Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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。
Expand Down
55 changes: 45 additions & 10 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
89 changes: 89 additions & 0 deletions test/layout-overlay.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})