import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import Tex from '../Tex' import type { Question } from '../../types' interface Props { q: Question value: Record | null | undefined onChange: (val: Record) => void locked?: boolean graded?: boolean } const PASTEL_COLORS = [ 'oklch(0.78 0.12 330)', // pink 'oklch(0.78 0.12 200)', // sky blue 'oklch(0.80 0.13 140)', // mint green 'oklch(0.78 0.12 80)', // peach / orange 'oklch(0.78 0.12 270)', // lavender 'oklch(0.82 0.10 60)', // soft yellow 'oklch(0.78 0.12 170)', // teal 'oklch(0.78 0.10 20)', // salmon 'oklch(0.78 0.12 300)', // orchid 'oklch(0.80 0.10 110)', // lime ] export default function Match({ q, value, onChange, locked, graded }: Props) { const current = value ?? {} const [activeLeft, setActiveLeft] = useState(null) const answer = (q.answer as Record) ?? {} const gridRef = useRef(null) const svgRef = useRef(null) const leftRefs = useRef>({}) const rightRefs = useRef>({}) const reverseMap = useMemo(() => { const m: Record = {} Object.entries(current).forEach(([l, r]) => { m[r] = l }) return m }, [current]) /* Build a stable color map: each pair index gets a pastel */ const pairColorMap = useMemo(() => { const map: Record = {} let colorIdx = 0 const leftItems = q.left ?? [] leftItems.forEach((item) => { if (current[item.id]) { map[item.id] = PASTEL_COLORS[colorIdx % PASTEL_COLORS.length] colorIdx++ } }) return map }, [current, q.left]) /* Stringify the current pairings so we can use it as a stable dependency */ const currentKey = JSON.stringify(current) const drawLines = useCallback(() => { const grid = gridRef.current const svg = svgRef.current if (!grid || !svg) return const gridRect = grid.getBoundingClientRect() /* Clear existing paths */ while (svg.firstChild) svg.removeChild(svg.firstChild) Object.entries(current).forEach(([leftId, rightId]) => { const leftEl = leftRefs.current[leftId] const rightEl = rightRefs.current[rightId] if (!leftEl || !rightEl) return const lr = leftEl.getBoundingClientRect() const rr = rightEl.getBoundingClientRect() const x1 = lr.right - gridRect.left const y1 = lr.top + lr.height / 2 - gridRect.top const x2 = rr.left - gridRect.left const y2 = rr.top + rr.height / 2 - gridRect.top const dx = (x2 - x1) * 0.45 const color = pairColorMap[leftId] ?? PASTEL_COLORS[0] const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') path.setAttribute('d', `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`) path.setAttribute('stroke', color) path.setAttribute('stroke-width', '2.5') path.setAttribute('fill', 'none') path.setAttribute('stroke-linecap', 'round') path.setAttribute('opacity', '0.85') svg.appendChild(path) }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentKey, pairColorMap]) useEffect(() => { /* Draw lines after render */ requestAnimationFrame(drawLines) window.addEventListener('resize', drawLines) window.addEventListener('scroll', drawLines, true) return () => { window.removeEventListener('resize', drawLines) window.removeEventListener('scroll', drawLines, true) } }, [drawLines]) const handleRight = (rightId: string) => { if (locked) return if (activeLeft) { const next = { ...current } Object.keys(next).forEach((k) => { if (k === activeLeft || next[k] === rightId) delete next[k] }) next[activeLeft] = rightId onChange(next) setActiveLeft(null) } else if (reverseMap[rightId]) { const next = { ...current } delete next[reverseMap[rightId]] onChange(next) } } const handleLeft = (leftId: string) => { if (locked) return if (current[leftId]) { const next = { ...current } delete next[leftId] onChange(next) return } setActiveLeft(activeLeft === leftId ? null : leftId) } return (
{/* SVG overlay for connection lines */}
Items
{(q.left ?? []).map((item, i) => { let cls = 'match-item' if (activeLeft === item.id) cls += ' active' else if (current[item.id]) cls += ' paired' if (graded) { if (answer[item.id] && current[item.id] === answer[item.id]) cls += ' correct' else if (current[item.id] && answer[item.id]) cls += ' wrong' } const pairedRightIdx = current[item.id] ? (q.right ?? []).findIndex((x) => x.id === current[item.id]) : -1 const pairColor = pairColorMap[item.id] return ( ) })}
Matches
{(q.right ?? []).map((item, i) => { let cls = 'match-item' if (reverseMap[item.id]) cls += ' paired' if (graded && reverseMap[item.id]) { cls += answer[reverseMap[item.id]] === item.id ? ' correct' : ' wrong' } const pairedLeftIdx = reverseMap[item.id] ? (q.left ?? []).findIndex((x) => x.id === reverseMap[item.id]) : -1 const pairColor = reverseMap[item.id] ? pairColorMap[reverseMap[item.id]] : undefined return ( ) })}
) }