Skip to content

Commit ae3e142

Browse files
Enter-tainerOpenclaw
andcommitted
fix: capture label clamps to both viewport edges without scroll listeners
- Move label out of .timeline-capture-move button into overlay direct child - Use position:fixed with useEffect to calculate viewport position on selection change (never re-calculates on scroll — no scroll listeners) - max/min branching clamps label to both left and right viewport edges - Remove will-change:transform on .lanes-container which caused position:fixed descendants to use the container as containing block instead of the viewport, breaking fixed positioning entirely - Replace old inline position:sticky label approach Co-authored-by: Openclaw <openclaw@local>
1 parent d7e412d commit ae3e142

2 files changed

Lines changed: 69 additions & 11 deletions

File tree

src/components/Dashboard.css

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,8 @@
274274
min-height: min-content;
275275
position: relative;
276276
transition: opacity 0.2s ease;
277-
will-change: transform;
277+
/* 这里不用 will-change: transform,否则 position: fixed 子元素
278+
的包含块从视口降级为这个元素,导致 fixed 定位失效。 */
278279
}
279280

280281
.lanes-container.is-capture-render,
@@ -570,13 +571,15 @@
570571
cursor: grabbing;
571572
}
572573

573-
.timeline-capture-move span {
574-
position: sticky;
575-
top: 15px;
576-
left: 50%;
577-
display: inline-flex;
574+
/*
575+
* label 用 position: fixed,在 selection 变化时(不监听滚动事件)
576+
* 通过 useEffect 重新计算视口位置,用 max/min 双侧钳制。
577+
* 渲染时计算一次,滚动时不重新计算,不卡。
578+
*/
579+
.timeline-capture-label-fixed {
578580
min-width: 34px;
579581
height: 20px;
582+
display: inline-flex;
580583
align-items: center;
581584
justify-content: center;
582585
padding: 0 7px;
@@ -587,8 +590,14 @@
587590
font-size: 11px;
588591
line-height: 1;
589592
font-weight: 700;
590-
transform: translateX(-50%);
591593
white-space: nowrap;
594+
pointer-events: none;
595+
z-index: 901;
596+
transition: opacity 0.15s ease;
597+
}
598+
599+
.timeline-capture-label-fixed.is-hidden {
600+
opacity: 0;
592601
}
593602

594603
@media (pointer: coarse) {

src/components/TimelineCaptureOverlay.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22
import type { PointerEvent as ReactPointerEvent } from 'react';
33
import {
44
type CaptureDragMode,
@@ -32,6 +32,48 @@ export default function TimelineCaptureOverlay({
3232
const left = selection.startIndex * hourWidth;
3333
const width = (selection.endIndex - selection.startIndex) * hourWidth;
3434
const totalWidth = dataLength * hourWidth;
35+
const hours = (selection.endIndex - selection.startIndex) * hoursPerColumn;
36+
37+
const [labelStyle, setLabelStyle] = useState<React.CSSProperties | null>(null);
38+
const prevLeftRef = useRef(left);
39+
const prevWidthRef = useRef(width);
40+
41+
// 在 selection 变化时(不监听滚动事件),计算 label 的 fixed 位置
42+
// 用 max/min 双侧钳制,只在选区变化时重新计算,滚动时不动
43+
useEffect(() => {
44+
// 跳过渲染时第一次触发(还没到初始渲染完成后)
45+
// 实际上只要 left/width 变了就执行
46+
const scroller = document.querySelector('.timeline-scroller');
47+
if (!scroller) return;
48+
49+
const scrollerRect = scroller.getBoundingClientRect();
50+
const scrollerScrollLeft = scroller.scrollLeft;
51+
52+
// 选区的视口 left
53+
const selLeftViewport = left - scrollerScrollLeft + scrollerRect.left;
54+
const selRightViewport = selLeftViewport + width;
55+
56+
// label 在选区中心
57+
const centre = (selLeftViewport + selRightViewport) / 2;
58+
59+
// 双侧钳制
60+
const pad = 4;
61+
const viewportWidth = window.innerWidth;
62+
const labelWidth = 34; // min-width: 34px + 2*7px padding = 34
63+
const rightBoundary = viewportWidth - labelWidth - pad;
64+
65+
let fixedLeft = centre - labelWidth / 2;
66+
fixedLeft = Math.max(pad, Math.min(rightBoundary, fixedLeft));
67+
68+
setLabelStyle({
69+
position: 'fixed',
70+
left: `${fixedLeft}px`,
71+
top: `${scrollerRect.top + 10}px`,
72+
});
73+
74+
prevLeftRef.current = left;
75+
prevWidthRef.current = width;
76+
}, [left, width]);
3577

3678
const startDrag = useCallback((mode: CaptureDragMode, event: ReactPointerEvent<HTMLElement>) => {
3779
event.preventDefault();
@@ -100,9 +142,7 @@ export default function TimelineCaptureOverlay({
100142
onPointerDown={(event) => startDrag('move', event)}
101143
aria-label="移动截图时间范围"
102144
title="移动截图范围"
103-
>
104-
<span>{(selection.endIndex - selection.startIndex) * hoursPerColumn}h</span>
105-
</button>
145+
/>
106146
<button
107147
type="button"
108148
className="timeline-capture-handle is-right"
@@ -111,6 +151,15 @@ export default function TimelineCaptureOverlay({
111151
title="调整结束时间"
112152
/>
113153
</div>
154+
{/* label 用 position: fixed,在 selection 变化时(不监听滚动事件)
155+
通过 useEffect 重新计算视口位置,用 max/min 双侧钳制。
156+
渲染时计算一次,滚动时不重新计算,不卡。 */}
157+
<span
158+
className={['timeline-capture-label-fixed', labelStyle ? '' : 'is-hidden'].filter(Boolean).join(' ')}
159+
style={labelStyle ?? undefined}
160+
>
161+
{hours}h
162+
</span>
114163
</div>
115164
);
116165
}

0 commit comments

Comments
 (0)