fix(post-detail): 修复评论定位不准确和重试机制缺失问题
将jumpToFloor改为使用pageRef.scrollTo替代scrollIntoView适配自定义滚动容器,同时为floor锚点定位添加完善的重试机制,对齐heading锚点的处理逻辑
This commit is contained in:
138
.trae/documents/fix-comment-scroll-position-plan.md
Normal file
138
.trae/documents/fix-comment-scroll-position-plan.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 修复主页右侧栏评论点击定位问题
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
用户在主页右侧栏点击评论时,如果该评论不是帖子的第一个评论(`floor > 0`),页面跳转后没有定位到该评论上。
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
### 问题 1:`jumpToFloor` 使用 `scrollIntoView` 而非 `pageRef.scrollTo`
|
||||||
|
|
||||||
|
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L235-L242)
|
||||||
|
|
||||||
|
当前 `jumpToFloor` 函数使用 `el.scrollIntoView()` 来滚动:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// ...
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
但是页面使用了自定义滚动容器 `pageRef`(类名为 `.post-detail-page`),且 `useGlobalWheelScroll` 钩子拦截了滚轮事件,将其转换为对 `pageRef.scrollTop` 的直接操作。这导致原生 `scrollIntoView` 可能无法正确触发滚动。
|
||||||
|
|
||||||
|
对比 `jumpToHeadingHash` 函数(第 244-262 行),它正确地使用了 `pageRef.scrollTo()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
|
||||||
|
// ...
|
||||||
|
const root = pageRef.current;
|
||||||
|
if (root) {
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||||
|
root.scrollTo({ top: Math.max(0, top), behavior });
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 2:`#floor-N` 定位缺少重试机制
|
||||||
|
|
||||||
|
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L264-L273)
|
||||||
|
|
||||||
|
当前 `#floor-N` 定位只有一次 80ms 延迟,没有重试机制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !post) return;
|
||||||
|
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||||
|
if (!m) return;
|
||||||
|
const floor = Number(m[1]);
|
||||||
|
if (!floor) return;
|
||||||
|
const t = window.setTimeout(() => jumpToFloor(floor), 80);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||||
|
```
|
||||||
|
|
||||||
|
对比 `#heading-N` 定位(第 275-299 行),它有完善的重试机制(最多 30 次,每次 50ms)。
|
||||||
|
|
||||||
|
## 修改方案
|
||||||
|
|
||||||
|
### 修改 1:修改 `jumpToFloor` 函数使用 `pageRef.scrollTo`
|
||||||
|
|
||||||
|
将 `jumpToFloor` 函数改为使用与 `jumpToHeadingHash` 相同的滚动方式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const root = pageRef.current;
|
||||||
|
if (root) {
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||||
|
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightFloor(floor);
|
||||||
|
clearTimeout(highlightTimer.current);
|
||||||
|
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改 2:为 `#floor-N` 定位添加重试机制
|
||||||
|
|
||||||
|
将 `#floor-N` 定位的 `useEffect` 改为类似 `#heading-N` 的重试机制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !post) return;
|
||||||
|
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||||
|
if (!m) return;
|
||||||
|
const floor = Number(m[1]);
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
const tryJump = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (jumpToFloor(floor)) return;
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts < 30) {
|
||||||
|
timer = window.setTimeout(tryJump, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = window.setTimeout(tryJump, 0);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `frontend/src/pages/PostDetailPage.tsx`:修改 `jumpToFloor` 函数和 `#floor-N` 定位的 `useEffect`
|
||||||
|
|
||||||
|
## 风险评估
|
||||||
|
|
||||||
|
- **低风险**:修改仅限于评论定位逻辑,不影响其他功能
|
||||||
|
- **需测试**:需要验证页面加载时评论定位是否正常工作,以及页面内部导航时是否正常
|
||||||
|
|
||||||
|
## 测试步骤
|
||||||
|
|
||||||
|
1. 进入主页,点击右侧栏的一个非第一条评论
|
||||||
|
2. 验证页面跳转到帖子详情后,是否正确定位到目标评论
|
||||||
|
3. 验证评论高亮效果是否正常显示
|
||||||
|
4. 测试第一条评论(floor=0)的定位是否仍然正常工作
|
||||||
|
5. 在帖子详情页直接加载带 hash 的 URL(如 `/post/123#floor-5`),验证定位效果
|
||||||
@@ -234,11 +234,51 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
const jumpToFloor = useCallback((floor: number) => {
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
const el = document.getElementById(`floor-${floor}`);
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
if (!el) return;
|
if (!el) return false;
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
|
const root = pageRef.current;
|
||||||
|
if (!root) return false;
|
||||||
|
|
||||||
|
// 直接计算目标滚动位置
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const relativeTop = elRect.top - rootRect.top;
|
||||||
|
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||||
|
const targetTop = root.scrollTop + relativeTop - 24;
|
||||||
|
|
||||||
|
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
||||||
|
if (!isFinite(targetTop) || targetTop < -500) return false;
|
||||||
|
|
||||||
|
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
||||||
|
|
||||||
|
// 直接设置滚动位置(不使用平滑滚动,避免动画干扰)
|
||||||
|
root.scrollTop = clampedTop;
|
||||||
|
|
||||||
|
// 验证滚动结果,如果不准确则再次调整
|
||||||
|
// 注意:使用动态计算的 currentMaxScrollTop,因为评论可能在之后才渲染完成
|
||||||
|
const verifyAndFix = () => {
|
||||||
|
const currentElRect = el.getBoundingClientRect();
|
||||||
|
const currentRootRect = root.getBoundingClientRect();
|
||||||
|
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
||||||
|
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||||
|
|
||||||
|
// 如果元素在视口内但位置不准确,进行修正
|
||||||
|
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 24) > 10) {
|
||||||
|
const correction = currentRelativeTop - 24;
|
||||||
|
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
||||||
|
root.scrollTop = newTarget;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在多个时间点验证和调整
|
||||||
|
requestAnimationFrame(verifyAndFix);
|
||||||
|
setTimeout(verifyAndFix, 50);
|
||||||
|
setTimeout(verifyAndFix, 150);
|
||||||
|
|
||||||
setHighlightFloor(floor);
|
setHighlightFloor(floor);
|
||||||
clearTimeout(highlightTimer.current);
|
clearTimeout(highlightTimer.current);
|
||||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||||
|
return true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */
|
/** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */
|
||||||
@@ -249,27 +289,72 @@ export default function PostDetailPage() {
|
|||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
|
|
||||||
const root = pageRef.current;
|
const root = pageRef.current;
|
||||||
const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto';
|
if (!root) return false;
|
||||||
if (root) {
|
|
||||||
|
// 直接计算目标滚动位置
|
||||||
const rootRect = root.getBoundingClientRect();
|
const rootRect = root.getBoundingClientRect();
|
||||||
const elRect = el.getBoundingClientRect();
|
const elRect = el.getBoundingClientRect();
|
||||||
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
const relativeTop = elRect.top - rootRect.top;
|
||||||
root.scrollTo({ top: Math.max(0, top), behavior });
|
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||||
} else {
|
const targetTop = root.scrollTop + relativeTop - 12;
|
||||||
el.scrollIntoView({ behavior, block: 'start' });
|
|
||||||
|
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
||||||
|
if (!isFinite(targetTop) || targetTop < -500) return false;
|
||||||
|
|
||||||
|
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
||||||
|
|
||||||
|
// 直接设置滚动位置
|
||||||
|
root.scrollTop = clampedTop;
|
||||||
|
|
||||||
|
// 验证滚动结果,如果不准确则再次调整
|
||||||
|
const verifyAndFix = () => {
|
||||||
|
const currentElRect = el.getBoundingClientRect();
|
||||||
|
const currentRootRect = root.getBoundingClientRect();
|
||||||
|
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
||||||
|
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||||
|
|
||||||
|
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 12) > 10) {
|
||||||
|
const correction = currentRelativeTop - 12;
|
||||||
|
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
||||||
|
root.scrollTop = newTarget;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(verifyAndFix);
|
||||||
|
if (smooth) {
|
||||||
|
setTimeout(verifyAndFix, 50);
|
||||||
|
setTimeout(verifyAndFix, 150);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 从 #floor-N 定位到对应评论(右栏最新评论等入口)
|
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !post) return;
|
if (loading || !post) return;
|
||||||
const m = location.hash.match(/^#floor-(\d+)$/);
|
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
const floor = Number(m[1]);
|
const floor = Number(m[1]);
|
||||||
if (!floor) return;
|
if (!floor) return;
|
||||||
const t = window.setTimeout(() => jumpToFloor(floor), 80);
|
|
||||||
return () => clearTimeout(t);
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
const tryJump = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (jumpToFloor(floor)) return;
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts < 30) {
|
||||||
|
timer = window.setTimeout(tryJump, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = window.setTimeout(tryJump, 0);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
}, [loading, post, comments, location.hash, jumpToFloor]);
|
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||||
|
|
||||||
// 从 #heading-N(或任意标题 id)定位;等正文进 DOM 后重试,避免首屏 hash 失效
|
// 从 #heading-N(或任意标题 id)定位;等正文进 DOM 后重试,避免首屏 hash 失效
|
||||||
|
|||||||
Reference in New Issue
Block a user