tipitaka / webapp /tipitaka-web /src /components /reader /ReferencePopup.tsx
dhammawatthumpra's picture
fix: merge adjacent <B> headings with <I> footnote refs, fix marker normalization exclusion list
6b67cae
Raw
History Blame Contribute Delete
13.2 kB
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;