Spaces:
Running
Running
fix: merge adjacent <B> headings with <I> footnote refs, fix marker normalization exclusion list
6b67cae | import React, { useEffect, useRef } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| export interface RefPopupPos { | |
| x: number; | |
| y: number; | |
| id: string; | |
| type: 'abbrev' | 'footnote'; | |
| vol: number; | |
| page: number; | |
| } | |
| interface Props { | |
| pos: RefPopupPos | null; | |
| onClose: () => void; | |
| onJump?: (id: string) => void; | |
| } | |
| const MIN_W = 240; | |
| const MAX_W = 600; | |
| const INIT_W = 320; | |
| const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => { | |
| const popupRef = useRef<HTMLDivElement>(null); | |
| const [content, setContent] = React.useState<string | null>(null); | |
| const [isLoading, setIsLoading] = React.useState(false); | |
| const [popupFontSize, setPopupFontSize] = React.useState(15); | |
| const [popupWidth, setPopupWidth] = React.useState(INIT_W); | |
| const [popupHeight, setPopupHeight] = React.useState<number | null>(null); | |
| const [visible, setVisible] = React.useState(false); | |
| // ── Position state (top-left of popup) ── | |
| const [posX, setPosX] = React.useState(0); | |
| const [posY, setPosY] = React.useState(0); | |
| const [showArrow, setShowArrow] = React.useState(false); | |
| const [arrowShift, setArrowShift] = React.useState(0); | |
| const [flipUp, setFlipUp] = React.useState(false); | |
| // ── Drag state ── | |
| const dragging = useRef(false); | |
| const dragStart = useRef({ x: 0, y: 0, px: 0, py: 0 }); | |
| // ── Resize state ── | |
| const resizing = useRef(false); | |
| const resizeStart = useRef({ x: 0, y: 0, w: INIT_W, h: 0 }); | |
| // ── Initial positioning when pos changes ── | |
| useEffect(() => { | |
| if (!pos) { | |
| setVisible(false); | |
| setContent(null); | |
| return; | |
| } | |
| setPopupWidth(INIT_W); | |
| setPopupHeight(null); | |
| // Fetch content | |
| const fetchRef = async () => { | |
| setIsLoading(true); | |
| try { | |
| const response = await fetch( | |
| `/api/reference/lookup?vol=${pos.vol}&page=${pos.page}&type=${pos.type}&id=${encodeURIComponent(pos.id)}` | |
| ); | |
| const data = await response.json(); | |
| setContent(data.content); | |
| } catch (err) { | |
| console.error("Failed to fetch reference:", err); | |
| setContent("เกิดข้อผิดพลาดในการโหลดข้อมูล"); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchRef(); | |
| // Calculate initial position (top-left of popup, not centered) | |
| requestAnimationFrame(() => { | |
| const viewW = window.innerWidth; | |
| const viewH = window.innerHeight; | |
| const halfW = INIT_W / 2; | |
| const EDGE = 12; | |
| // Start: sup center → convert to top-left | |
| let left = pos.x - halfW; | |
| let top = pos.y + 6; // 6px below sup | |
| let flip = false; | |
| // Horizontal clamp | |
| if (left < EDGE) left = EDGE; | |
| else if (left + INIT_W > viewW - EDGE) left = viewW - INIT_W - EDGE; | |
| // Arrow horizontal shift | |
| const centerX = left + INIT_W / 2; | |
| let aShift = pos.x - centerX; | |
| // Vertical flip if near bottom | |
| const estH = 260; | |
| if (pos.y + estH + 20 > viewH) { | |
| flip = true; | |
| top = pos.y - 6 - estH; | |
| if (top < EDGE) top = EDGE; | |
| } | |
| setPosX(left); | |
| setPosY(top); | |
| setArrowShift(aShift); | |
| setFlipUp(flip); | |
| setShowArrow(true); | |
| setVisible(true); | |
| // Fine-tune after render | |
| setTimeout(() => { | |
| if (popupRef.current) { | |
| const actualH = popupRef.current.offsetHeight; | |
| if (flip && pos.y - actualH - 6 < EDGE) { | |
| setPosY(EDGE); | |
| setFlipUp(false); | |
| } else if (!flip && pos.y + actualH + 20 > viewH) { | |
| setPosY(Math.max(EDGE, viewH - actualH - 10)); | |
| setFlipUp(true); | |
| } | |
| } | |
| }, 60); | |
| }); | |
| }, [pos]); | |
| // ── Drag handler ── | |
| const onDragStart = React.useCallback((e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| dragging.current = true; | |
| dragStart.current = { x: e.clientX, y: e.clientY, px: posX, py: posY }; | |
| setShowArrow(false); | |
| const onMove = (ev: MouseEvent) => { | |
| if (!dragging.current) return; | |
| setPosX(dragStart.current.px + (ev.clientX - dragStart.current.x)); | |
| setPosY(dragStart.current.py + (ev.clientY - dragStart.current.y)); | |
| }; | |
| const onUp = () => { dragging.current = false; }; | |
| document.addEventListener('mousemove', onMove); | |
| document.addEventListener('mouseup', onUp, { once: true }); | |
| }, [posX, posY]); | |
| // ── Resize handler ── | |
| const onResizeStart = React.useCallback((e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| resizing.current = true; | |
| resizeStart.current = { x: e.clientX, y: e.clientY, w: popupWidth, h: popupHeight || 300 }; | |
| const onMove = (ev: MouseEvent) => { | |
| if (!resizing.current) return; | |
| const newW = Math.max(MIN_W, Math.min(MAX_W, resizeStart.current.w + (ev.clientX - resizeStart.current.x))); | |
| const newH = Math.max(150, resizeStart.current.h + (ev.clientY - resizeStart.current.y)); | |
| setPopupWidth(newW); | |
| setPopupHeight(newH); | |
| }; | |
| const onUp = () => { resizing.current = false; }; | |
| document.addEventListener('mousemove', onMove); | |
| document.addEventListener('mouseup', onUp, { once: true }); | |
| }, [popupWidth, popupHeight]); | |
| // ── Click outside ── | |
| useEffect(() => { | |
| const onMouseDown = (e: MouseEvent | TouchEvent) => { | |
| const target = e.target as HTMLElement; | |
| if (target.tagName?.toLowerCase() === 'sup' && | |
| (target.classList.contains('abbrev-ref') || target.classList.contains('footnote-ref'))) return; | |
| if (popupRef.current && !popupRef.current.contains(e.target as Node)) onClose(); | |
| }; | |
| document.addEventListener('mousedown', onMouseDown); | |
| document.addEventListener('touchstart', onMouseDown); | |
| return () => { | |
| document.removeEventListener('mousedown', onMouseDown); | |
| document.removeEventListener('touchstart', onMouseDown); | |
| }; | |
| }, [onClose]); | |
| return ( | |
| <AnimatePresence> | |
| {pos && ( | |
| <motion.div | |
| ref={popupRef} | |
| key="ref-popup" | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ | |
| opacity: visible ? 1 : 0, | |
| scale: visible ? 1 : 0.95, | |
| }} | |
| exit={{ opacity: 0, scale: 0.95 }} | |
| transition={{ duration: 0.12, ease: 'easeOut' }} | |
| style={{ | |
| position: 'fixed', | |
| left: posX, | |
| top: posY, | |
| zIndex: 9998, | |
| width: popupWidth, | |
| height: popupHeight || 'auto', | |
| maxHeight: popupHeight ? 'none' : (flipUp ? '50vh' : 'calc(100vh - 120px)'), | |
| }} | |
| className="rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-[#1a1a2e]/95 backdrop-blur-xl border border-white/10 text-[#e8e4da] overflow-hidden flex flex-col" | |
| > | |
| {/* Gradient top bar */} | |
| <div className="h-1 bg-gradient-to-r from-[#c8860a]/0 via-[#c8860a] to-[#c8860a]/0 shrink-0" /> | |
| <div className="p-4 flex flex-col flex-1 min-h-0"> | |
| {/* Header — drag handle */} | |
| <div | |
| onMouseDown={onDragStart} | |
| className="flex items-center justify-between mb-3 pb-2 border-b border-white/5 shrink-0 cursor-grab active:cursor-grabbing select-none" | |
| > | |
| <div className="flex items-center gap-2 min-w-0"> | |
| {/* Drag indicator */} | |
| <svg className="w-3.5 h-3.5 text-white/15 shrink-0" viewBox="0 0 24 24" fill="currentColor"> | |
| <circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/> | |
| <circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/> | |
| <circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/> | |
| </svg> | |
| <span className="bg-[#c8860a] text-[#1a1a2e] text-xs font-bold px-1.5 py-0.5 rounded shrink-0"> | |
| {pos.id} | |
| </span> | |
| <span className="text-xs font-medium text-white/50 tracking-wide uppercase truncate"> | |
| {pos.type === 'abbrev' ? 'คำย่อ' : 'เชิงอรรถ'} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1 shrink-0"> | |
| <button onClick={() => setPopupFontSize(s => Math.max(s - 2, 11))} | |
| className="text-white/30 hover:text-white/70 transition-colors p-0.5" title="ย่อ"> | |
| <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 10h-4m-4 0H6" /> | |
| </svg> | |
| </button> | |
| <button onClick={() => setPopupFontSize(15)} | |
| className="text-[9px] text-white/20 hover:text-white/50 transition-colors font-mono w-3.5 text-center" title="รีเซ็ต"> | |
| A | |
| </button> | |
| <button onClick={() => setPopupFontSize(s => Math.min(s + 2, 28))} | |
| className="text-white/30 hover:text-white/70 transition-colors p-0.5" title="ขยาย"> | |
| <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> | |
| </svg> | |
| </button> | |
| <button onClick={onClose} | |
| className="text-white/30 hover:text-white/70 transition-colors ml-1"> | |
| <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Content */} | |
| <div className="overflow-y-auto custom-scrollbar pr-1 flex-1 min-h-0"> | |
| {isLoading ? ( | |
| <div className="space-y-3 py-1"> | |
| <div className="h-3 bg-white/10 rounded-full w-full animate-pulse" /> | |
| <div className="h-3 bg-white/10 rounded-full w-5/6 animate-pulse" /> | |
| <div className="h-3 bg-white/10 rounded-full w-4/6 animate-pulse" /> | |
| </div> | |
| ) : ( | |
| <div className="leading-relaxed font-light text-white/90" | |
| style={{ fontSize: `${popupFontSize}px` }}> | |
| {content || ( | |
| <span className="text-white/40 italic">ไม่พบข้อมูลอ้างอิง</span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {pos.type === 'abbrev' && !isLoading && ( | |
| <div className="mt-3 pt-2 border-t border-white/5 flex items-center gap-1.5 shrink-0"> | |
| <div className="w-1.5 h-1.5 rounded-full bg-[#c8860a] animate-pulse" /> | |
| <span className="text-[10px] text-white/30 uppercase tracking-tighter">AI ขยายความ</span> | |
| </div> | |
| )} | |
| {pos.type === 'footnote' && !isLoading && onJump && ( | |
| <div className="mt-4 pt-3 border-t border-white/5 shrink-0"> | |
| <button onClick={() => onJump(pos.id)} | |
| className="w-full py-2 px-3 rounded-xl bg-white/5 hover:bg-white/10 text-[#c8860a] text-xs font-bold transition-all flex items-center justify-center gap-2 group"> | |
| <svg className="w-3.5 h-3.5 transform group-hover:translate-y-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" /> | |
| </svg> | |
| ดูเนื้อหาเต็มด้านล่าง | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Resize handle */} | |
| <div onMouseDown={onResizeStart} | |
| className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize group"> | |
| <svg className="w-full h-full text-white/20 group-hover:text-[#c8860a] transition-colors" | |
| viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} | |
| strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M21 15v4a2 2 0 01-2 2h-4" /> | |
| <path d="M21 3v4a2 2 0 01-2 2h-4" /> | |
| </svg> | |
| </div> | |
| {/* Tail arrow — hidden after manual drag */} | |
| {showArrow && ( | |
| flipUp ? ( | |
| <div className="pointer-events-none absolute left-1/2 -bottom-[5px] w-2.5 h-2.5 bg-[#1a1a2e] border-r border-b border-white/10 -rotate-45" | |
| style={{ marginLeft: arrowShift }} /> | |
| ) : ( | |
| <div className="pointer-events-none absolute left-1/2 -top-[5px] w-2.5 h-2.5 bg-[#1a1a2e] border-l border-t border-white/10 rotate-45" | |
| style={{ marginLeft: arrowShift }} /> | |
| ) | |
| )} | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| ); | |
| }; | |
| export default ReferencePopup; | |