Public Access
feat: add interactive matching question component with responsive connection lines and styles
This commit is contained in:
1 parent
eb013d4978
commit
5c7b0bdb45
2 files changed
+124
-6
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import Tex from '../Tex'
|
||||
import type { Question } from '../../types'
|
||||
|
||||
@@ -10,17 +10,102 @@ interface Props {
|
||||
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) {
|
||||
@@ -48,7 +133,10 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="match-grid">
|
||||
<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) => {
|
||||
@@ -62,14 +150,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
|
||||
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}>
|
||||
<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">{String.fromCharCode(65 + pairedRightIdx)}</span>
|
||||
<span
|
||||
className="match-pair-badge"
|
||||
style={pairColor ? { background: pairColor, color: '#fff' } : undefined}
|
||||
>
|
||||
{String.fromCharCode(65 + pairedRightIdx)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@@ -86,19 +187,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
|
||||
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">{pairedLeftIdx + 1}</span>
|
||||
<span
|
||||
className="match-pair-badge"
|
||||
style={pairColor ? { background: pairColor, color: '#fff' } : undefined}
|
||||
>
|
||||
{pairedLeftIdx + 1}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user