Public Access
219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
|
import Tex from '../Tex'
|
|
import type { Question } from '../../types'
|
|
|
|
interface Props {
|
|
q: Question
|
|
value: Record<string, string> | null | undefined
|
|
onChange: (val: Record<string, string>) => 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<string | null>(null)
|
|
const answer = (q.answer as Record<string, string>) ?? {}
|
|
|
|
const gridRef = useRef<HTMLDivElement>(null)
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const leftRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
|
const rightRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
|
|
|
const reverseMap = useMemo(() => {
|
|
const m: Record<string, string> = {}
|
|
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<string, string> = {}
|
|
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 (
|
|
<div className="match-grid" ref={gridRef}>
|
|
{/* SVG overlay for connection lines */}
|
|
<svg className="match-lines-svg" ref={svgRef} />
|
|
|
|
<div className="match-col">
|
|
<div className="match-col-label">Items</div>
|
|
{(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 (
|
|
<button
|
|
key={item.id}
|
|
className={cls}
|
|
onClick={() => handleLeft(item.id)}
|
|
disabled={locked}
|
|
ref={(el) => { leftRefs.current[item.id] = el }}
|
|
style={pairColor && !graded ? { borderColor: pairColor, borderRightWidth: 3 } : undefined}
|
|
>
|
|
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
|
<span className="mono faint" style={{ fontSize: 11 }}>{i + 1}</span>
|
|
<Tex>{item.text}</Tex>
|
|
</span>
|
|
{pairedRightIdx >= 0 && (
|
|
<span
|
|
className="match-pair-badge"
|
|
style={pairColor ? { background: pairColor, color: '#fff' } : undefined}
|
|
>
|
|
{String.fromCharCode(65 + pairedRightIdx)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="match-col">
|
|
<div className="match-col-label">Matches</div>
|
|
{(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 (
|
|
<button
|
|
key={item.id}
|
|
className={cls}
|
|
onClick={() => handleRight(item.id)}
|
|
disabled={locked || (!activeLeft && !reverseMap[item.id])}
|
|
ref={(el) => { rightRefs.current[item.id] = el }}
|
|
style={pairColor && !graded ? { borderColor: pairColor, borderLeftWidth: 3 } : undefined}
|
|
>
|
|
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
|
<span className="mono faint" style={{ fontSize: 11 }}>{String.fromCharCode(65 + i)}</span>
|
|
<Tex>{item.text}</Tex>
|
|
</span>
|
|
{pairedLeftIdx >= 0 && (
|
|
<span
|
|
className="match-pair-badge"
|
|
style={pairColor ? { background: pairColor, color: '#fff' } : undefined}
|
|
>
|
|
{pairedLeftIdx + 1}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|