Skip to content

Commit 3e5fe0e

Browse files
committed
feat(nav-arrows): implement NavArrows component for improved navigation and visibility control
1 parent 87256d1 commit 3e5fe0e

3 files changed

Lines changed: 202 additions & 131 deletions

File tree

packages/react-file-preview/src/FilePreviewContent.tsx

Lines changed: 95 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -156,25 +156,6 @@ const FilePreviewContentInner: React.FC<FilePreviewContentProps> = ({
156156
const [textHtmlPreview, setTextHtmlPreview] = useState(false);
157157
const [markdownViewMode, setMarkdownViewMode] = useState<'preview' | 'source'>('preview');
158158

159-
// 导航箭头自动隐藏
160-
const [navVisible, setNavVisible] = useState(true);
161-
const navHideTimerRef = useRef<number | null>(null);
162-
const NAV_HIDE_DELAY = 2000;
163-
164-
const resetNavTimer = useCallback(() => {
165-
setNavVisible(true);
166-
if (navHideTimerRef.current) {
167-
clearTimeout(navHideTimerRef.current);
168-
}
169-
navHideTimerRef.current = window.setTimeout(() => {
170-
setNavVisible(false);
171-
}, NAV_HIDE_DELAY);
172-
}, []);
173-
174-
const handleMouseMove = useCallback(() => {
175-
resetNavTimer();
176-
}, [resetNavTimer]);
177-
178159
// 标准化文件输入
179160
const normalizedFiles = useMemo(() => normalizeFiles(files), [files]);
180161

@@ -220,7 +201,6 @@ const FilePreviewContentInner: React.FC<FilePreviewContentProps> = ({
220201
setContentNaturalWidth(0);
221202
setContentNaturalHeight(0);
222203
setImageResetKey(0);
223-
setNavVisible(true);
224204
// 重置 epub 状态
225205
setEpubCurrent(0);
226206
setEpubTotal(0);
@@ -236,9 +216,6 @@ const FilePreviewContentInner: React.FC<FilePreviewContentProps> = ({
236216
setTextHtmlPreview(false);
237217
// 重置 markdown 状态
238218
setMarkdownViewMode('preview');
239-
if (navHideTimerRef.current) {
240-
clearTimeout(navHideTimerRef.current);
241-
}
242219
}, [currentIndex]);
243220

244221
// 图片加载后默认适应窗口
@@ -253,18 +230,6 @@ const FilePreviewContentInner: React.FC<FilePreviewContentProps> = ({
253230
}
254231
}, [fileType, contentNaturalWidth, contentNaturalHeight]);
255232

256-
// 导航箭头自动隐藏计时器启动 & 清理
257-
useEffect(() => {
258-
if (normalizedFiles.length > 1) {
259-
resetNavTimer();
260-
}
261-
return () => {
262-
if (navHideTimerRef.current) {
263-
clearTimeout(navHideTimerRef.current);
264-
}
265-
};
266-
}, [normalizedFiles.length, resetNavTimer]);
267-
268233
// 键盘导航
269234
// - modal 模式:全局监听(window)
270235
// - embed 模式:只在根容器 focus 时监听,避免影响外部页面交互
@@ -534,7 +499,6 @@ const FilePreviewContentInner: React.FC<FilePreviewContentProps> = ({
534499
<div
535500
ref={contentRef}
536501
className="rfp-flex-1 rfp-flex rfp-items-center rfp-justify-center rfp-overflow-auto"
537-
onMouseMove={handleMouseMove}
538502
>
539503
{customRenderer ? (
540504
customRenderer.render(currentFile, customCtx)
@@ -631,37 +595,16 @@ const FilePreviewContentInner: React.FC<FilePreviewContentProps> = ({
631595
)}
632596
</div>
633597

634-
{/* 左右导航箭头 - 自动隐藏 */}
598+
{/* 左右导航箭头 - 自动隐藏(隔离 state,避免拖选时的 mousemove/timer 污染整树 re-render) */}
635599
{!headless && normalizedFiles.length > 1 && (
636-
<>
637-
{currentIndex > 0 && (
638-
<motion.button
639-
initial={{ opacity: 0 }}
640-
animate={{ opacity: navVisible ? 1 : 0, x: navVisible ? 0 : -20 }}
641-
transition={{ duration: 0.2 }}
642-
onClick={() => onNavigate?.(currentIndex - 1)}
643-
onMouseEnter={() => setNavVisible(true)}
644-
style={{ pointerEvents: navVisible ? 'auto' : 'none' }}
645-
className="rfp-absolute rfp-z-20 rfp-left-2 md:rfp-left-4 rfp-top-1/2 -rfp-translate-y-1/2 rfp-w-10 rfp-h-10 md:rfp-w-12 md:rfp-h-12 rfp-rounded-full rfp-backdrop-blur-xl rfp-border rfp-flex rfp-items-center rfp-justify-center rfp-transition-colors rfp-shadow-2xl rfp-bg-surface-nav rfp-border-line hover:rfp-bg-surface-nav-hover rfp-text-fg-primary"
646-
>
647-
<ChevronLeft className="rfp-w-5 rfp-h-5 md:rfp-w-6 md:rfp-h-6" />
648-
</motion.button>
649-
)}
650-
651-
{currentIndex < normalizedFiles.length - 1 && (
652-
<motion.button
653-
initial={{ opacity: 0 }}
654-
animate={{ opacity: navVisible ? 1 : 0, x: navVisible ? 0 : 20 }}
655-
transition={{ duration: 0.2 }}
656-
onClick={() => onNavigate?.(currentIndex + 1)}
657-
onMouseEnter={() => setNavVisible(true)}
658-
style={{ pointerEvents: navVisible ? 'auto' : 'none' }}
659-
className="rfp-absolute rfp-z-20 rfp-right-2 md:rfp-right-4 rfp-top-1/2 -rfp-translate-y-1/2 rfp-w-10 rfp-h-10 md:rfp-w-12 md:rfp-h-12 rfp-rounded-full rfp-backdrop-blur-xl rfp-border rfp-flex rfp-items-center rfp-justify-center rfp-transition-colors rfp-shadow-2xl rfp-bg-surface-nav rfp-border-line hover:rfp-bg-surface-nav-hover rfp-text-fg-primary"
660-
>
661-
<ChevronRight className="rfp-w-5 rfp-h-5 md:rfp-w-6 md:rfp-h-6" />
662-
</motion.button>
663-
)}
664-
</>
600+
<NavArrows
601+
containerRef={contentRef}
602+
hasPrev={currentIndex > 0}
603+
hasNext={currentIndex < normalizedFiles.length - 1}
604+
onPrev={() => onNavigate?.(currentIndex - 1)}
605+
onNext={() => onNavigate?.(currentIndex + 1)}
606+
resetKey={currentIndex}
607+
/>
665608
)}
666609
</div>
667610
</ThemeProvider>
@@ -696,3 +639,89 @@ const ToolbarButton: React.FC<ToolbarButtonProps> = ({ icon, label, onClick, dis
696639
</button>
697640
);
698641
};
642+
643+
// 导航箭头:自带 mousemove 监听 + 2s 自动隐藏定时器,
644+
// state 隔离在本组件,避免 FilePreviewContent 整树因 navVisible 变化而 re-render
645+
interface NavArrowsProps {
646+
containerRef: React.RefObject<HTMLDivElement | null>;
647+
hasPrev: boolean;
648+
hasNext: boolean;
649+
onPrev: () => void;
650+
onNext: () => void;
651+
resetKey: number;
652+
}
653+
654+
const NAV_HIDE_DELAY = 2000;
655+
656+
const NavArrows: React.FC<NavArrowsProps> = ({
657+
containerRef,
658+
hasPrev,
659+
hasNext,
660+
onPrev,
661+
onNext,
662+
resetKey,
663+
}) => {
664+
const [visible, setVisible] = useState(true);
665+
const timerRef = useRef<number | null>(null);
666+
667+
const scheduleHide = useCallback(() => {
668+
if (timerRef.current) clearTimeout(timerRef.current);
669+
timerRef.current = window.setTimeout(() => setVisible(false), NAV_HIDE_DELAY);
670+
}, []);
671+
672+
const show = useCallback(() => {
673+
setVisible((prev) => (prev ? prev : true));
674+
scheduleHide();
675+
}, [scheduleHide]);
676+
677+
// 监听容器的 mousemove,触发显示+重置隐藏定时器
678+
useEffect(() => {
679+
const el = containerRef.current;
680+
if (!el) return;
681+
const handler = () => show();
682+
el.addEventListener('mousemove', handler);
683+
return () => {
684+
el.removeEventListener('mousemove', handler);
685+
};
686+
}, [containerRef, show]);
687+
688+
// currentIndex 切换时,显示一次并重置定时器
689+
useEffect(() => {
690+
setVisible(true);
691+
scheduleHide();
692+
return () => {
693+
if (timerRef.current) clearTimeout(timerRef.current);
694+
};
695+
}, [resetKey, scheduleHide]);
696+
697+
return (
698+
<>
699+
{hasPrev && (
700+
<motion.button
701+
initial={{ opacity: 0 }}
702+
animate={{ opacity: visible ? 1 : 0, x: visible ? 0 : -20 }}
703+
transition={{ duration: 0.2 }}
704+
onClick={onPrev}
705+
onMouseEnter={show}
706+
style={{ pointerEvents: visible ? 'auto' : 'none' }}
707+
className="rfp-absolute rfp-z-20 rfp-left-2 md:rfp-left-4 rfp-top-1/2 -rfp-translate-y-1/2 rfp-w-10 rfp-h-10 md:rfp-w-12 md:rfp-h-12 rfp-rounded-full rfp-backdrop-blur-xl rfp-border rfp-flex rfp-items-center rfp-justify-center rfp-transition-colors rfp-shadow-2xl rfp-bg-surface-nav rfp-border-line hover:rfp-bg-surface-nav-hover rfp-text-fg-primary"
708+
>
709+
<ChevronLeft className="rfp-w-5 rfp-h-5 md:rfp-w-6 md:rfp-h-6" />
710+
</motion.button>
711+
)}
712+
{hasNext && (
713+
<motion.button
714+
initial={{ opacity: 0 }}
715+
animate={{ opacity: visible ? 1 : 0, x: visible ? 0 : 20 }}
716+
transition={{ duration: 0.2 }}
717+
onClick={onNext}
718+
onMouseEnter={show}
719+
style={{ pointerEvents: visible ? 'auto' : 'none' }}
720+
className="rfp-absolute rfp-z-20 rfp-right-2 md:rfp-right-4 rfp-top-1/2 -rfp-translate-y-1/2 rfp-w-10 rfp-h-10 md:rfp-w-12 md:rfp-h-12 rfp-rounded-full rfp-backdrop-blur-xl rfp-border rfp-flex rfp-items-center rfp-justify-center rfp-transition-colors rfp-shadow-2xl rfp-bg-surface-nav rfp-border-line hover:rfp-bg-surface-nav-hover rfp-text-fg-primary"
721+
>
722+
<ChevronRight className="rfp-w-5 rfp-h-5 md:rfp-w-6 md:rfp-h-6" />
723+
</motion.button>
724+
)}
725+
</>
726+
);
727+
};

packages/vue-file-preview/src/FilePreviewContent.vue

Lines changed: 12 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import { ref, computed, watch, onMounted, onBeforeUnmount, toRef, provide } from 'vue';
3-
import { X, Download, ChevronLeft, ChevronRight } from 'lucide-vue-next';
3+
import { X, Download } from 'lucide-vue-next';
44
import {
55
normalizeFiles,
66
getFileType,
@@ -50,6 +50,7 @@ import {
5050
} from './renderers/lazy';
5151
// Unsupported 体量极小且每次回退都用,直接静态打包到主入口
5252
import UnsupportedRenderer from './renderers/Unsupported/index.vue';
53+
import NavArrows from './components/NavArrows.vue';
5354
5455
const MAX_ZIP_NESTING_DEPTH = 3;
5556
@@ -153,23 +154,6 @@ const imageResetKey = ref(0);
153154
const contentRef = ref<HTMLDivElement | null>(null);
154155
const rootRef = ref<HTMLDivElement | null>(null);
155156
156-
// 导航箭头自动隐藏
157-
const navVisible = ref(true);
158-
let navHideTimer: number | null = null;
159-
const NAV_HIDE_DELAY = 2000;
160-
161-
const resetNavTimer = () => {
162-
navVisible.value = true;
163-
if (navHideTimer !== null) {
164-
clearTimeout(navHideTimer);
165-
}
166-
navHideTimer = window.setTimeout(() => {
167-
navVisible.value = false;
168-
}, NAV_HIDE_DELAY);
169-
};
170-
171-
const handleMouseMove = () => resetNavTimer();
172-
173157
// 标准化文件输入
174158
const normalizedFiles = computed(() => normalizeFiles(props.files));
175159
@@ -220,7 +204,6 @@ watch(
220204
contentNaturalWidth.value = 0;
221205
contentNaturalHeight.value = 0;
222206
imageResetKey.value = 0;
223-
navVisible.value = true;
224207
// 重置 epub 状态
225208
epubCurrent.value = 0;
226209
epubTotal.value = 0;
@@ -236,9 +219,6 @@ watch(
236219
textHtmlPreview.value = false;
237220
// 重置 markdown 状态
238221
markdownViewMode.value = 'preview';
239-
if (navHideTimer !== null) {
240-
clearTimeout(navHideTimer);
241-
}
242222
}
243223
);
244224
@@ -262,15 +242,6 @@ watch(
262242
}
263243
);
264244
265-
// 导航箭头自动隐藏计时器
266-
watch(
267-
() => normalizedFiles.value.length,
268-
(len) => {
269-
if (len > 1) resetNavTimer();
270-
},
271-
{ immediate: true }
272-
);
273-
274245
// 键盘导航
275246
const handleKeyDown = (e: KeyboardEvent) => {
276247
if (e.key === 'Escape' && props.mode === 'modal') {
@@ -291,7 +262,6 @@ onMounted(() => {
291262
});
292263
293264
onBeforeUnmount(() => {
294-
if (navHideTimer !== null) clearTimeout(navHideTimer);
295265
if (props.mode === 'modal') {
296266
window.removeEventListener('keydown', handleKeyDown);
297267
} else if (rootRef.value) {
@@ -595,7 +565,6 @@ const hasToolGroups = computed(() => toolGroups.value.length > 0);
595565
<div
596566
ref="contentRef"
597567
class="vfp-flex-1 vfp-flex vfp-items-center vfp-justify-center vfp-overflow-auto"
598-
@mousemove="handleMouseMove"
599568
:key="currentFile?.url"
600569
>
601570
<template v-if="currentFile">
@@ -698,38 +667,16 @@ const hasToolGroups = computed(() => toolGroups.value.length > 0);
698667
</template>
699668
</div>
700669

701-
<!-- 左右导航箭头 -->
702-
<template v-if="!headless && normalizedFiles.length > 1">
703-
<button
704-
v-if="currentIndex > 0"
705-
:style="{
706-
opacity: navVisible ? 1 : 0,
707-
transform: navVisible ? 'translateY(-50%)' : 'translateY(-50%) translateX(-20px)',
708-
pointerEvents: navVisible ? 'auto' : 'none',
709-
transition: 'opacity 0.2s, transform 0.2s',
710-
}"
711-
class="vfp-absolute vfp-z-20 vfp-left-2 md:vfp-left-4 vfp-top-1/2 vfp-w-10 vfp-h-10 md:vfp-w-12 md:vfp-h-12 vfp-rounded-full vfp-backdrop-blur-xl vfp-border vfp-flex vfp-items-center vfp-justify-center vfp-transition-colors vfp-shadow-2xl vfp-bg-surface-nav vfp-border-line hover:vfp-bg-surface-nav-hover vfp-text-fg-primary"
712-
@click="emit('navigate', currentIndex - 1)"
713-
@mouseenter="navVisible = true"
714-
>
715-
<ChevronLeft class="vfp-w-5 vfp-h-5 md:vfp-w-6 md:vfp-h-6" />
716-
</button>
717-
718-
<button
719-
v-if="currentIndex < normalizedFiles.length - 1"
720-
:style="{
721-
opacity: navVisible ? 1 : 0,
722-
transform: navVisible ? 'translateY(-50%)' : 'translateY(-50%) translateX(20px)',
723-
pointerEvents: navVisible ? 'auto' : 'none',
724-
transition: 'opacity 0.2s, transform 0.2s',
725-
}"
726-
class="vfp-absolute vfp-z-20 vfp-right-2 md:vfp-right-4 vfp-top-1/2 vfp-w-10 vfp-h-10 md:vfp-w-12 md:vfp-h-12 vfp-rounded-full vfp-backdrop-blur-xl vfp-border vfp-flex vfp-items-center vfp-justify-center vfp-transition-colors vfp-shadow-2xl vfp-bg-surface-nav vfp-border-line hover:vfp-bg-surface-nav-hover vfp-text-fg-primary"
727-
@click="emit('navigate', currentIndex + 1)"
728-
@mouseenter="navVisible = true"
729-
>
730-
<ChevronRight class="vfp-w-5 vfp-h-5 md:vfp-w-6 md:vfp-h-6" />
731-
</button>
732-
</template>
670+
<!-- 左右导航箭头:state 隔离在 NavArrows 内部,避免 mousemove/timer 引起整树 patch -->
671+
<NavArrows
672+
v-if="!headless && normalizedFiles.length > 1"
673+
:container-ref="contentRef"
674+
:has-prev="currentIndex > 0"
675+
:has-next="currentIndex < normalizedFiles.length - 1"
676+
:reset-key="currentIndex"
677+
@prev="emit('navigate', currentIndex - 1)"
678+
@next="emit('navigate', currentIndex + 1)"
679+
/>
733680
</div>
734681
</template>
735682

0 commit comments

Comments
 (0)