1- import React , { useState , useRef , useCallback } from 'react'
1+ import React , { useState , useRef , useLayoutEffect } from 'react'
22import { createPortal } from 'react-dom'
33import { Input , Tag } from 'antd'
44import { BookOutlined , ReadOutlined , SearchOutlined , CloseOutlined } from '@ant-design/icons'
5- import { AnimatePresence , motion } from 'framer-motion'
65import InteractiveBook from '@stateless/InteractiveBook'
76import styles from './index.module.less'
87
@@ -281,6 +280,12 @@ const bookLibraryData = [
281280
282281const allCategories = [ 'All' , ...Array . from ( new Set ( bookLibraryData . map ( ( b ) => b . category ) ) ) ]
283282
283+ // ─── 弹窗行为配置 ─────────────────────────────────
284+ const OVERLAY_CONFIG = {
285+ showCloseButton : true , // 是否显示关闭按钮
286+ maskClosable : true , // 点击蒙层是否关闭
287+ }
288+
284289// ─── BookLibrary 组件 ────────────────────────────
285290export default function BookLibrary ( ) {
286291 const [ activeCategory , setActiveCategory ] = useState ( 'All' )
@@ -297,6 +302,9 @@ export default function BookLibrary() {
297302 const cardRefsMap = useRef ( { } )
298303 const overlayContentRef = useRef ( null )
299304 const maskRef = useRef ( null )
305+ const flySourceRef = useRef ( null )
306+ const openCleanupRef = useRef ( null )
307+ const selectedBookRef = useRef ( null )
300308
301309 const filteredBooks = bookLibraryData . filter ( ( book ) => {
302310 const matchCategory = activeCategory === 'All' || book . category === activeCategory
@@ -309,39 +317,103 @@ export default function BookLibrary() {
309317
310318 const openBook = ( book ) => {
311319 if ( closeTimerRef . current ) clearTimeout ( closeTimerRef . current )
320+ if ( openCleanupRef . current ) clearTimeout ( openCleanupRef . current )
312321 closePhaseRef . current = null
322+
323+ // 在 setState 之前捕获源卡片的位置
324+ const cardEl = cardRefsMap . current [ book . id ]
325+ flySourceRef . current = cardEl ? cardEl . getBoundingClientRect ( ) : null
326+
313327 setSelectedBook ( book )
328+ selectedBookRef . current = book
314329 setDialogOpen ( true )
315330 setBookOpen ( true )
316- // 重置 DOM 样式(防止上次飞回残留)
331+ }
332+
333+ // ─── 飞出动画:卡片位置 → 屏幕中心(useLayoutEffect 在 paint 前执行) ───
334+ useLayoutEffect ( ( ) => {
335+ if ( ! dialogOpen || ! selectedBook || ! flySourceRef . current ) return
336+
337+ const contentEl = overlayContentRef . current
338+ const maskEl = maskRef . current
339+ const sourceRect = flySourceRef . current
340+ flySourceRef . current = null
341+
342+ if ( ! contentEl ) return
343+
344+ // 计算卡片中心 → 内容中心 的偏移量
345+ const contentRect = contentEl . getBoundingClientRect ( )
346+ const tx = sourceRect . left + sourceRect . width / 2 - ( contentRect . left + contentRect . width / 2 )
347+ const ty = sourceRect . top + sourceRect . height / 2 - ( contentRect . top + contentRect . height / 2 )
348+ const ts = Math . max ( sourceRect . width / contentRect . width , 0.12 )
349+
350+ // ── 第 1 帧:无过渡,直接定位到卡片位置(小 + 透明) ──
351+ contentEl . style . transition = 'none'
352+ contentEl . style . transform = `translate(${ tx } px, ${ ty } px) scale(${ ts } )`
353+ contentEl . style . opacity = '0'
354+ contentEl . style . overflow = 'hidden'
355+ contentEl . style . borderRadius = '12px'
356+
357+ if ( maskEl ) {
358+ maskEl . style . transition = 'none'
359+ maskEl . style . opacity = '0'
360+ }
361+
362+ // 强制重排,提交初始状态到渲染管线
363+
364+ contentEl . offsetHeight
365+
366+ // ── 第 2 帧:启用过渡,飞向中心(逐渐放大 + 淡入) ──
317367 requestAnimationFrame ( ( ) => {
318- const el = overlayContentRef . current
319- const mask = maskRef . current
320- if ( el ) {
321- el . style . transition = ''
322- el . style . transform = ''
323- el . style . opacity = ''
324- el . style . overflow = ''
325- el . style . borderRadius = ''
368+ contentEl . style . transition =
369+ 'transform 0.55s cubic-bezier(0.22,1,0.36,1), ' + 'opacity 0.4s ease, ' + 'border-radius 0.45s ease'
370+ contentEl . style . transform = 'translate(0,0) scale(1)'
371+ contentEl . style . opacity = '1'
372+ contentEl . style . borderRadius = '0px'
373+
374+ if ( maskEl ) {
375+ maskEl . style . transition = 'opacity 0.4s ease'
376+ maskEl . style . opacity = '1'
326377 }
327- if ( mask ) {
328- mask . style . transition = ''
329- mask . style . opacity = ''
378+
379+ // 触发分段渐变(顺向)
380+ const seg = contentEl . querySelector ( '.segmentGradient' )
381+ if ( seg ) {
382+ seg . classList . remove ( 'seg-animate-reverse' )
383+
384+ seg . offsetHeight
385+ seg . classList . add ( 'seg-animate' )
330386 }
331387 } )
332- }
388+
389+ // 动画结束后清理内联样式,为飞回做准备
390+ openCleanupRef . current = setTimeout ( ( ) => {
391+ if ( ! contentEl ) return
392+ contentEl . style . transition = ''
393+ contentEl . style . transform = ''
394+ contentEl . style . opacity = ''
395+ contentEl . style . overflow = ''
396+ contentEl . style . borderRadius = ''
397+ } , 620 )
398+
399+ return ( ) => {
400+ if ( openCleanupRef . current ) clearTimeout ( openCleanupRef . current )
401+ }
402+ } , [ dialogOpen , selectedBook ] )
333403
334404 // 执行飞回动画(Phase 2):直接操作 DOM + CSS transition
335- const doFlyback = useCallback ( ( ) => {
405+ const doFlyback = ( ) => {
336406 const contentEl = overlayContentRef . current
337407 const maskEl = maskRef . current
338- const cardEl = selectedBook ? cardRefsMap . current [ selectedBook . id ] : null
408+ const book = selectedBookRef . current
409+ const cardEl = book ? cardRefsMap . current [ book . id ] : null
339410
340411 if ( ! contentEl || ! cardEl ) {
341412 // 无法计算目标,直接卸载
342413 setDialogOpen ( false )
343414 closePhaseRef . current = null
344415 setSelectedBook ( null )
416+ selectedBookRef . current = null
345417 setBookOpen ( true )
346418 return
347419 }
@@ -354,18 +426,30 @@ export default function BookLibrary() {
354426
355427 // 先设 transition,下一帧设目标值,确保浏览器识别为动画
356428 contentEl . style . overflow = 'hidden'
357- contentEl . style . borderRadius = '12px'
358- contentEl . style . transition = 'transform 0.5s cubic-bezier(0.4,0,0.2,1), opacity 0.5s cubic-bezier(0.4,0,0.2,1)'
429+ contentEl . style . borderRadius = '0px'
430+ contentEl . style . transition =
431+ 'transform 0.5s cubic-bezier(0.4,0,0.2,1), ' +
432+ 'opacity 0.5s cubic-bezier(0.4,0,0.2,1), ' +
433+ 'border-radius 0.4s cubic-bezier(0.4,0,0.2,1)'
359434
360435 if ( maskEl ) {
361436 maskEl . style . transition = 'opacity 0.5s cubic-bezier(0.4,0,0.2,1)'
362437 }
363438
439+ // 分段渐变逆向动画,制造飞回时的层次感
440+ const seg = contentEl . querySelector ( '.segmentGradient' )
441+ if ( seg ) {
442+ seg . classList . remove ( 'seg-animate' )
443+ seg . offsetHeight
444+ seg . classList . add ( 'seg-animate-reverse' )
445+ }
446+
364447 // 下一帧应用目标值
365448 requestAnimationFrame ( ( ) => {
366449 requestAnimationFrame ( ( ) => {
367450 contentEl . style . transform = `translate(${ tx } px, ${ ty } px) scale(${ ts } )`
368451 contentEl . style . opacity = '0'
452+ contentEl . style . borderRadius = '12px'
369453 if ( maskEl ) {
370454 maskEl . style . opacity = '0'
371455 }
@@ -377,12 +461,13 @@ export default function BookLibrary() {
377461 setDialogOpen ( false )
378462 closePhaseRef . current = null
379463 setSelectedBook ( null )
464+ selectedBookRef . current = null
380465 setBookOpen ( true )
381466 } , 530 )
382- } , [ selectedBook ] )
467+ }
383468
384469 // 分段关闭入口:Phase 1 关闭书籍 → Phase 2 飞回
385- const startClose = useCallback ( ( ) => {
470+ const startClose = ( ) => {
386471 if ( closePhaseRef . current ) return
387472 closePhaseRef . current = 'book'
388473
@@ -394,10 +479,10 @@ export default function BookLibrary() {
394479 closePhaseRef . current = 'flyback'
395480 doFlyback ( )
396481 } , 650 )
397- } , [ doFlyback ] )
482+ }
398483
399484 // InteractiveBook 内部关闭按钮触发(书已自行开始关闭动画)
400- const handleBookInternalClose = useCallback ( ( ) => {
485+ const handleBookInternalClose = ( ) => {
401486 if ( closePhaseRef . current ) return
402487 closePhaseRef . current = 'book'
403488
@@ -406,7 +491,7 @@ export default function BookLibrary() {
406491 closePhaseRef . current = 'flyback'
407492 doFlyback ( )
408493 } , 650 )
409- } , [ doFlyback ] )
494+ }
410495
411496 return (
412497 < section className = { styles . bookLibrary } >
@@ -441,38 +526,31 @@ export default function BookLibrary() {
441526
442527 { /* 书籍网格(带过滤动画) */ }
443528 < div className = { styles . bookGrid } >
444- < AnimatePresence mode = "popLayout" >
445- { filteredBooks . map ( ( book ) => (
446- < motion . div
447- key = { book . id }
448- ref = { ( el ) => {
449- cardRefsMap . current [ book . id ] = el
450- } }
451- layout
452- initial = { { opacity : 0 , scale : 0.8 } }
453- animate = { { opacity : 1 , scale : 1 } }
454- exit = { { opacity : 0 , scale : 0.6 } }
455- transition = { { duration : 0.35 , type : 'spring' , stiffness : 300 , damping : 25 } }
456- className = { styles . bookCard }
457- onClick = { ( ) => openBook ( book ) }
458- >
459- < div className = { styles . bookCoverWrap } >
460- < img src = { book . cover } alt = { book . title } className = { styles . bookCoverImg } loading = "lazy" />
461- < div className = { styles . bookCoverOverlay } >
462- < BookOutlined style = { { fontSize : 28 , color : '#fff' } } />
463- < span > 阅读</ span >
464- </ div >
465- </ div >
466- < div className = { styles . bookMeta } >
467- < h3 className = { styles . bookCardTitle } > { book . title } </ h3 >
468- < p className = { styles . bookCardAuthor } > { book . author } </ p >
469- < Tag color = "blue" style = { { fontSize : 11 } } >
470- { book . category }
471- </ Tag >
529+ { filteredBooks . map ( ( book ) => (
530+ < div
531+ key = { book . id }
532+ ref = { ( el ) => {
533+ cardRefsMap . current [ book . id ] = el
534+ } }
535+ className = { styles . bookCard }
536+ onClick = { ( ) => openBook ( book ) }
537+ >
538+ < div className = { styles . bookCoverWrap } >
539+ < img src = { book . cover } alt = { book . title } className = { styles . bookCoverImg } loading = "lazy" />
540+ < div className = { styles . bookCoverOverlay } >
541+ < BookOutlined style = { { fontSize : 28 , color : '#fff' } } />
542+ < span > 阅读</ span >
472543 </ div >
473- </ motion . div >
474- ) ) }
475- </ AnimatePresence >
544+ </ div >
545+ < div className = { styles . bookMeta } >
546+ < h3 className = { styles . bookCardTitle } > { book . title } </ h3 >
547+ < p className = { styles . bookCardAuthor } > { book . author } </ p >
548+ < Tag color = "blue" style = { { fontSize : 11 } } >
549+ { book . category }
550+ </ Tag >
551+ </ div >
552+ </ div >
553+ ) ) }
476554
477555 { filteredBooks . length === 0 && (
478556 < div className = { styles . bookEmpty } >
@@ -486,11 +564,24 @@ export default function BookLibrary() {
486564 { dialogOpen &&
487565 selectedBook &&
488566 createPortal (
489- < div ref = { maskRef } className = { styles . bookOverlayMask } onClick = { startClose } >
490- < div ref = { overlayContentRef } className = { styles . bookOverlayContent } onClick = { ( e ) => e . stopPropagation ( ) } >
567+ < div
568+ ref = { maskRef }
569+ className = { styles . bookOverlayMask }
570+ onClick = { OVERLAY_CONFIG . maskClosable ? startClose : undefined }
571+ >
572+ { /* 关闭按钮放在 mask 层,避免被 content 的 overflow:hidden 裁剪 */ }
573+ { OVERLAY_CONFIG . showCloseButton && (
491574 < button className = { styles . bookOverlayClose } onClick = { startClose } aria-label = "关闭" >
492575 < CloseOutlined />
493576 </ button >
577+ ) }
578+ < div ref = { overlayContentRef } className = { styles . bookOverlayContent } onClick = { ( e ) => e . stopPropagation ( ) } >
579+ { /* 分段渐变覆层(用于飞出/飞入时的视觉效果) */ }
580+ < div className = { styles . segmentGradient + ' segmentGradient' } >
581+ { Array . from ( { length : 6 } ) . map ( ( _ , i ) => (
582+ < div key = { i } className = { `segmentStrip segmentStrip-${ i + 1 } ` } />
583+ ) ) }
584+ </ div >
494585 < InteractiveBook
495586 coverImage = { selectedBook . cover }
496587 bookTitle = { selectedBook . title }
0 commit comments