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 Tex from '../Tex'
|
||||||
import type { Question } from '../../types'
|
import type { Question } from '../../types'
|
||||||
|
|
||||||
@@ -10,17 +10,102 @@ interface Props {
|
|||||||
graded?: 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) {
|
export default function Match({ q, value, onChange, locked, graded }: Props) {
|
||||||
const current = value ?? {}
|
const current = value ?? {}
|
||||||
const [activeLeft, setActiveLeft] = useState<string | null>(null)
|
const [activeLeft, setActiveLeft] = useState<string | null>(null)
|
||||||
const answer = (q.answer as Record<string, string>) ?? {}
|
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 reverseMap = useMemo(() => {
|
||||||
const m: Record<string, string> = {}
|
const m: Record<string, string> = {}
|
||||||
Object.entries(current).forEach(([l, r]) => { m[r] = l })
|
Object.entries(current).forEach(([l, r]) => { m[r] = l })
|
||||||
return m
|
return m
|
||||||
}, [current])
|
}, [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) => {
|
const handleRight = (rightId: string) => {
|
||||||
if (locked) return
|
if (locked) return
|
||||||
if (activeLeft) {
|
if (activeLeft) {
|
||||||
@@ -48,7 +133,10 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<div className="match-col-label">Items</div>
|
<div className="match-col-label">Items</div>
|
||||||
{(q.left ?? []).map((item, i) => {
|
{(q.left ?? []).map((item, i) => {
|
||||||
@@ -62,14 +150,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
|
|||||||
const pairedRightIdx = current[item.id]
|
const pairedRightIdx = current[item.id]
|
||||||
? (q.right ?? []).findIndex((x) => x.id === current[item.id])
|
? (q.right ?? []).findIndex((x) => x.id === current[item.id])
|
||||||
: -1
|
: -1
|
||||||
|
const pairColor = pairColorMap[item.id]
|
||||||
return (
|
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 style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
<span className="mono faint" style={{ fontSize: 11 }}>{i + 1}</span>
|
<span className="mono faint" style={{ fontSize: 11 }}>{i + 1}</span>
|
||||||
<Tex>{item.text}</Tex>
|
<Tex>{item.text}</Tex>
|
||||||
</span>
|
</span>
|
||||||
{pairedRightIdx >= 0 && (
|
{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>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -86,19 +187,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
|
|||||||
const pairedLeftIdx = reverseMap[item.id]
|
const pairedLeftIdx = reverseMap[item.id]
|
||||||
? (q.left ?? []).findIndex((x) => x.id === reverseMap[item.id])
|
? (q.left ?? []).findIndex((x) => x.id === reverseMap[item.id])
|
||||||
: -1
|
: -1
|
||||||
|
const pairColor = reverseMap[item.id] ? pairColorMap[reverseMap[item.id]] : undefined
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={cls}
|
className={cls}
|
||||||
onClick={() => handleRight(item.id)}
|
onClick={() => handleRight(item.id)}
|
||||||
disabled={locked || (!activeLeft && !reverseMap[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 style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
<span className="mono faint" style={{ fontSize: 11 }}>{String.fromCharCode(65 + i)}</span>
|
<span className="mono faint" style={{ fontSize: 11 }}>{String.fromCharCode(65 + i)}</span>
|
||||||
<Tex>{item.text}</Tex>
|
<Tex>{item.text}</Tex>
|
||||||
</span>
|
</span>
|
||||||
{pairedLeftIdx >= 0 && (
|
{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>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-1
@@ -655,9 +655,18 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
|
|||||||
.match-grid {
|
.match-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 32px;
|
gap: 48px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.match-lines-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
.match-col {
|
.match-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in new issue
Block a user