feat: add learning maps viewer

This commit is contained in:
ittoview
2026-04-28 09:05:11 +01:00
parent daf94170df
commit 297728c367
9 changed files with 385 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import ProcessPracticePage from './pages/ProcessPracticePage'
import PrinciplesPage from './pages/PrinciplesPage'
import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage'
import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage'
import { LearningMapsPage } from './pages/LearningMapsPage'
function App() {
return (
@@ -31,6 +32,7 @@ function App() {
<Route path="/principles" element={<PrinciplesPage />} />
<Route path="/performance-domains" element={<PerformanceDomainsPage />} />
<Route path="/performance-domains/:id" element={<PerformanceDomainsPage />} />
<Route path="/learning-maps" element={<LearningMapsPage />} />
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
<Route path="/tool/:id" element={<ToolDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />

0
src/data/image/.gitkeep Normal file
View File

BIN
src/data/image/合同.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
src/data/image/挣值1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

BIN
src/data/image/挣值2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,382 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { PointerEvent, WheelEvent } from 'react'
import { motion } from 'framer-motion'
import { ChevronLeft, ChevronRight, Clipboard, Download, RotateCcw, X, ZoomIn } from 'lucide-react'
import { clsx } from 'clsx'
type LearningMapImage = {
src: string
fileName: string
title: string
}
type Point = {
x: number
y: number
}
const imageModules = import.meta.glob('../data/image/*.{png,jpg,jpeg,webp,avif,gif}', {
eager: true,
import: 'default',
query: '?url',
}) as Record<string, string>
const learningMapImages: LearningMapImage[] = Object.entries(imageModules)
.map(([path, src]) => {
const fileName = decodeURIComponent(path.split('/').pop() || 'learning-map')
const title = fileName.replace(/\.[^.]+$/, '')
return { src, fileName, title }
})
.sort((a, b) => a.fileName.localeCompare(b.fileName, 'zh-CN', { numeric: true }))
function getDistance(a: Point, b: Point) {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function getMidpoint(a: Point, b: Point) {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}
function clampScale(value: number) {
return Math.min(5, Math.max(1, value))
}
async function blobToPngBlob(blob: Blob) {
const bitmap = await createImageBitmap(blob)
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const context = canvas.getContext('2d')
if (!context) throw new Error('无法读取图片')
context.drawImage(bitmap, 0, 0)
bitmap.close()
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((pngBlob) => {
if (pngBlob) resolve(pngBlob)
else reject(new Error('无法复制图片'))
}, 'image/png')
})
}
export function LearningMapsPage() {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [message, setMessage] = useState('')
const pointersRef = useRef(new Map<number, Point>())
const lastDragPointRef = useRef<Point | null>(null)
const pinchRef = useRef<{ distance: number; scale: number; midpoint: Point } | null>(null)
const activeImage = activeIndex === null ? null : learningMapImages[activeIndex]
const hasPrevious = activeIndex !== null && activeIndex > 0
const hasNext = activeIndex !== null && activeIndex < learningMapImages.length - 1
const viewerStyle = useMemo(
() => ({ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }),
[offset.x, offset.y, scale]
)
const showMessage = (text: string) => {
setMessage(text)
window.setTimeout(() => setMessage(''), 1800)
}
const resetView = () => {
setScale(1)
setOffset({ x: 0, y: 0 })
pointersRef.current.clear()
lastDragPointRef.current = null
pinchRef.current = null
setIsDragging(false)
}
const openViewer = (index: number) => {
setActiveIndex(index)
resetView()
}
const closeViewer = () => {
setActiveIndex(null)
resetView()
}
const goPrevious = () => {
setActiveIndex((current) => (current === null || current <= 0 ? current : current - 1))
resetView()
}
const goNext = () => {
setActiveIndex((current) => (current === null || current >= learningMapImages.length - 1 ? current : current + 1))
resetView()
}
const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
const delta = event.deltaY > 0 ? -0.18 : 0.18
setScale((current) => {
const nextScale = clampScale(Number((current + delta).toFixed(2)))
if (nextScale === 1) setOffset({ x: 0, y: 0 })
return nextScale
})
}
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId)
const point = { x: event.clientX, y: event.clientY }
pointersRef.current.set(event.pointerId, point)
if (pointersRef.current.size === 1) {
lastDragPointRef.current = point
setIsDragging(true)
}
if (pointersRef.current.size === 2) {
const [first, second] = Array.from(pointersRef.current.values())
pinchRef.current = { distance: getDistance(first, second), scale, midpoint: getMidpoint(first, second) }
setIsDragging(false)
}
}
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!pointersRef.current.has(event.pointerId)) return
const point = { x: event.clientX, y: event.clientY }
pointersRef.current.set(event.pointerId, point)
if (pointersRef.current.size === 2 && pinchRef.current) {
const [first, second] = Array.from(pointersRef.current.values())
const distance = getDistance(first, second)
const midpoint = getMidpoint(first, second)
const nextScale = clampScale(pinchRef.current.scale * (distance / pinchRef.current.distance))
setScale(nextScale)
setOffset((current) => ({
x: current.x + (midpoint.x - pinchRef.current!.midpoint.x) * 0.8,
y: current.y + (midpoint.y - pinchRef.current!.midpoint.y) * 0.8,
}))
pinchRef.current.midpoint = midpoint
return
}
if (pointersRef.current.size === 1 && lastDragPointRef.current && scale > 1) {
const previous = lastDragPointRef.current
setOffset((current) => ({ x: current.x + point.x - previous.x, y: current.y + point.y - previous.y }))
lastDragPointRef.current = point
}
}
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
pointersRef.current.delete(event.pointerId)
pinchRef.current = null
if (pointersRef.current.size === 1) {
lastDragPointRef.current = Array.from(pointersRef.current.values())[0]
setIsDragging(true)
} else {
lastDragPointRef.current = null
setIsDragging(false)
}
if (scale <= 1) {
setScale(1)
setOffset({ x: 0, y: 0 })
}
}
const downloadImage = () => {
if (!activeImage) return
const link = document.createElement('a')
link.href = activeImage.src
link.download = activeImage.fileName
document.body.appendChild(link)
link.click()
link.remove()
}
const copyImage = async () => {
if (!activeImage) return
try {
if (!navigator.clipboard || !window.ClipboardItem) {
showMessage('可下载后保存使用')
return
}
const response = await fetch(activeImage.src)
const blob = await response.blob()
const pngBlob = blob.type === 'image/png' ? blob : await blobToPngBlob(blob)
await navigator.clipboard.write([new ClipboardItem({ [pngBlob.type]: pngBlob })])
showMessage('图片已复制')
} catch {
showMessage('可下载后保存使用')
}
}
useEffect(() => {
if (activeIndex === null) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeViewer()
if (event.key === 'ArrowLeft') goPrevious()
if (event.key === 'ArrowRight') goNext()
}
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
return () => {
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
}
}, [activeIndex])
return (
<div className="mx-auto max-w-7xl space-y-5">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"></p>
</div>
{learningMapImages.length > 0 ? (
<motion.div
initial="hidden"
animate="visible"
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.04 } } }}
className="columns-1 gap-4 sm:columns-2 xl:columns-3"
>
{learningMapImages.map((image, index) => (
<motion.button
key={image.fileName}
type="button"
variants={{ hidden: { opacity: 0, y: 12 }, visible: { opacity: 1, y: 0 } }}
onClick={() => openViewer(index)}
className="group mb-4 block w-full break-inside-avoid overflow-hidden rounded-2xl bg-white text-left shadow-sm ring-1 ring-gray-200 transition duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200 dark:bg-gray-800 dark:ring-gray-700 dark:hover:ring-indigo-500/50"
>
<img
src={image.src}
alt={image.title}
loading="lazy"
className="w-full bg-gray-100 object-contain transition duration-300 group-hover:scale-[1.01] dark:bg-gray-900"
/>
</motion.button>
))}
</motion.div>
) : (
<div className="rounded-2xl bg-white p-8 text-center text-sm text-gray-500 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700">
</div>
)}
{activeImage && activeIndex !== null && (
<div className="fixed inset-0 z-50 bg-gray-950/95 text-white">
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 py-3">
<button
type="button"
onClick={closeViewer}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20"
aria-label="关闭"
>
<X size={22} />
</button>
<div className="min-w-0 flex-1 text-center text-sm text-white/80">
{activeIndex + 1} / {learningMapImages.length}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={resetView}
className="hidden h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20 sm:flex"
>
<RotateCcw size={17} />
</button>
<button
type="button"
onClick={copyImage}
className="flex h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20"
>
<Clipboard size={17} />
<span className="hidden sm:inline"></span>
</button>
<button
type="button"
onClick={downloadImage}
className="flex h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20"
>
<Download size={17} />
<span className="hidden sm:inline"></span>
</button>
</div>
</div>
{message && (
<div className="absolute left-1/2 top-16 z-20 -translate-x-1/2 rounded-full bg-white px-4 py-2 text-sm font-medium text-gray-900 shadow-xl">
{message}
</div>
)}
<button
type="button"
onClick={goPrevious}
disabled={!hasPrevious}
className={clsx(
'absolute left-3 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20 md:flex',
!hasPrevious && 'cursor-not-allowed opacity-30'
)}
aria-label="上一张"
>
<ChevronLeft size={30} />
</button>
<button
type="button"
onClick={goNext}
disabled={!hasNext}
className={clsx(
'absolute right-3 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20 md:flex',
!hasNext && 'cursor-not-allowed opacity-30'
)}
aria-label="下一张"
>
<ChevronRight size={30} />
</button>
<div
className={clsx(
'flex h-full w-full items-center justify-center overflow-hidden px-3 pb-20 pt-20 touch-none',
scale > 1 && isDragging ? 'cursor-grabbing' : scale > 1 ? 'cursor-grab' : 'cursor-zoom-in'
)}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={() => {
setScale((current) => (current === 1 ? 2.4 : 1))
if (scale !== 1) setOffset({ x: 0, y: 0 })
}}
>
<img
src={activeImage.src}
alt={activeImage.title}
draggable={false}
style={viewerStyle}
className="max-h-full max-w-full select-none object-contain transition-transform duration-75"
/>
</div>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-xs text-white/75 backdrop-blur">
<ZoomIn size={15} />
<span> · </span>
</div>
</div>
)}
</div>
)
}

View File

@@ -10,3 +10,4 @@ export { ToolDetailPage } from './ToolDetailPage'
export { SettingsPage } from './SettingsPage'
export { PerformanceDomainsPage } from './PerformanceDomainsPage'
export { LearningMapsPage } from './LearningMapsPage'