import React, { useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import './styles.css'
const files = [
{ id: 'echo', no: '01', title: '回声', subtitle: 'ECHOES', year: '2021' },
{ id: 'peach', no: '02', title: '误入桃源', subtitle: 'THE PEACH SPRING', year: '2022' },
{ id: 'sleep', no: '03', title: '眠史', subtitle: 'THE HISTORY OF SLEEP', year: '2022' },
{ id: 'reverse', no: '04', title: '逆溯备忘录', subtitle: 'REVERSE MEMORANDUM', year: '2023' },
{ id: 'specimen', no: '05', title: '夜航样本', subtitle: 'NIGHT SPECIMEN', year: '2024' },
{ id: 'tide', no: '06', title: '潮汐信札', subtitle: 'TIDAL LETTERS', year: '2024' },
{ id: 'garden', no: '07', title: '无声花园', subtitle: 'THE SILENT GARDEN', year: '2025' },
{ id: 'latitude', no: '08', title: '失落纬度', subtitle: 'LOST LATITUDES', year: '2026' },
]
const TRACK_START = 7
const TRACK_STEP = 16.5
const TRACK_END = 80
function useReducedMotion() {
const [reduced, setReduced] = useState(() => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setReduced(media.matches)
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
return reduced
}
function Mark({ index, opening = false, reducedMotion = false }) {
const styles = [
{ wash: '#c7d8d4', ink: '#536f73' },
{ wash: '#cbded5', ink: '#2b5a60' },
{ wash: '#b9d1de', ink: '#31566d' },
{ wash: '#d5d0d9', ink: '#6d5b6b' },
{ wash: '#b7ced2', ink: '#284d5b' },
][index % 5]
return (
)
}
function FileCard({ file, index, total, onOpen, hovered, onHover, opening, reducedMotion }) {
const hasHover = hovered !== null
// 只允许 React 的 is-active 控制转正;不要再添加 CSS :hover 放大,否则会同时弹出两张档案。
const isActive = opening || hovered === index
// 固定 16.5% 间距让封面标签可读;超出视口的档案通过拖动/滚轮带入画面。
const baseX = TRACK_START + index * TRACK_STEP
// 档案越多默认越侧放:5 张约 18°,当前 8 张约 45°。背景卡片不跟随当前卡片改变朝向。
const densityYaw = Math.max(0, total - 5) * 9
const yaw = isActive ? 0 : -18 - densityYaw + index * .6
const roll = isActive ? 0 : -1.8 + index * (3.4 / Math.max(total - 1, 1))
const depth = isActive ? 210 : -42 - index * 7
const scale = isActive ? 1.48 : total > 5 ? .9 : .96
return (
)
}
function Header({ activeNo, count }) {
return (
)
}
function ArchiveDetail({ file, onClose }) {
const closeButtonRef = useRef(null)
useEffect(() => {
closeButtonRef.current?.focus()
}, [])
return (
文学实验 · 视觉叙事 · 概念设计
{file.title}
WORLD
FILESARCHIVE
《{file.title}》是一份关于记忆、空间与叙述偏差的世界观档案。它把一个看似完整的地方拆成可以被观察、记录与反复质疑的规则。
LITERARY ADAPTATIONNARRATIVE PUZZLEWORLD BUILDING
01 · 核心概念
被记录的世界
档案中的世界并不是等待探索的固定地图,而是一组会因观察者而改变的证词。人物对同一段经历拥有不同版本,地点也会随着记录方式改变边界。读者需要从互相矛盾的材料里判断:哪些是真实发生的事件,哪些只是为了让世界继续运转而被共同相信的故事。
这种不稳定性并非谜底揭晓前的障眼法,而是世界本身的基本规则。每一次翻阅、批注和重新归档都会留下痕迹,让“阅读档案”成为叙事的一部分。
02 · 世界规则
不可复返
漂泊中的旅者误入一处从未被正式记录的聚落。每次回望,来路都会减少一个细节;每次试图留下,记忆里便多出一道无法解释的裂隙。离开并不意味着走出边界,而是逐渐失去证明自己曾经抵达的能力。
“有些地方不是找不到,而是只允许被找到一次。”
规则通过缺页、重复编号、相互覆盖的地图和逐渐变化的称谓呈现。它们不会由旁白一次解释清楚,而是在多份材料之间保持一致,让读者能够自行验证。
03 · 叙事结构
记录与遗忘
纸张上的批注、被水浸开的地图、口述片段与失效的时间表共同组成叙事。每一份材料只承担一个局部视角,重要信息则通过不同材料间的重合和冲突浮现。
读者不是旁观者,而是负责整理证据的最后一位档案员。章节顺序可以被打乱,但关键线索会形成足够稳定的路径,使自由阅读不会破坏故事的理解。
04 · 视觉系统
纸张、校勘与水痕
低饱和纸色建立长期保存的物质感,红色校勘标记负责提示人为干预,水墨状图形则对应无法被精确归类的内容。标签、编号和页边索引保持克制,让视觉信息像真实工作文件一样可以快速扫描。
档案袋的厚边、袋口阴影和底部投影共同制造轻量的 2.5D 体积;所有文字与图形跟随同一张卡片旋转,Hover 时才完整转向阅读者。
05 · 项目复盘
从观看转向参与
这一套展示方式的核心并不是模拟真实档案管理,而是把作品集的选择动作变成一次带有仪式感的开启。卡片侧放时强调数量与层次,转正时强调当前选择,解结和展开则把短暂的等待转化成进入故事的过渡。
后续内容可以继续复用同一结构,只替换标题、摘要与关键段落。这样既保留统一的交互语言,也避免为了演示前端效果维护八套完全独立的页面系统。
)
}
function OpeningLetter({ active, sourceRect, onClose, reducedMotion }) {
const [expanded, setExpanded] = useState(reducedMotion)
const [revealed, setRevealed] = useState(reducedMotion)
useEffect(() => {
if (reducedMotion) {
setExpanded(true)
setRevealed(true)
return undefined
}
// 开档顺序:下一帧启动 FLIP → .68s 解结完成 → 约 1.33s 纸张铺开 → 1.34s 显示正文。
const frame = requestAnimationFrame(() => setExpanded(true))
const revealTimer = setTimeout(() => setRevealed(true), 1340)
return () => { cancelAnimationFrame(frame); clearTimeout(revealTimer) }
}, [reducedMotion])
const style = sourceRect ? {
'--from-x': `${sourceRect.left}px`, '--from-y': `${sourceRect.top}px`,
'--from-w': `${sourceRect.width}px`, '--from-h': `${sourceRect.height}px`,
} : undefined
return (
WORLD FILES / {active.no}
{revealed &&
}
)
}
function App() {
const [active, setActive] = useState(null)
const [sourceRect, setSourceRect] = useState(null)
const [hovered, setHovered] = useState(null)
const [dragOffset, setDragOffset] = useState(0)
const [dragging, setDragging] = useState(false)
const [wheeling, setWheeling] = useState(false)
// Pointer Capture 会把原生 click 交给父容器,因此这里保存按下的原卡片,并在 pointerup 主动区分点击和拖动。
const dragState = useRef({ pointerId: null, startX: 0, startOffset: 0, moved: false, cardIndex: null, cardNode: null })
// pointerup 主动打开后,吞掉浏览器可能补发的一次 click,避免开档动画启动两遍。
const suppressClick = useRef(false)
const wheelTimer = useRef(null)
const stackRef = useRef(null)
const triggerRef = useRef(null)
const reducedMotion = useReducedMotion()
const minDragOffset = Math.min(0, TRACK_END - (TRACK_START + (files.length - 1) * TRACK_STEP))
const openFile = (file, index, node) => {
if (!node) return
const rect = node.getBoundingClientRect()
triggerRef.current = node
setHovered(index)
setSourceRect({ left: rect.left, top: rect.top, width: rect.width, height: rect.height })
setActive({ ...file, index })
}
const clickFile = (file, index, node) => {
if (suppressClick.current) {
suppressClick.current = false
return
}
openFile(file, index, node)
}
const closeFile = () => {
setActive(null)
setSourceRect(null)
setHovered(null)
requestAnimationFrame(() => triggerRef.current?.focus())
}
const trackPointer = (event) => {
if (event.pointerType === 'touch' || active || dragState.current.pointerId !== null) return
const stack = stackRef.current
if (!stack) return
const rect = stack.getBoundingClientRect()
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) {
setHovered(null)
return
}
const pointerX = ((event.clientX - rect.left) / rect.width) * 100
if (pointerX < 2 || pointerX > 88) {
setHovered(null)
return
}
// 命中轨道必须扣除当前拖动偏移;否则拖动后视觉卡片与 Hover 索引会错位。
const index = Math.max(0, Math.min(files.length - 1, Math.round((pointerX - TRACK_START - dragOffset) / TRACK_STEP)))
setHovered((current) => current === index ? current : index)
}
const startDrag = (event) => {
if (active || event.button !== 0) return
const cardNode = event.target.closest?.('.file-card')
const cardIndex = cardNode ? Number(cardNode.dataset.fileIndex) : null
dragState.current = { pointerId: event.pointerId, startX: event.clientX, startOffset: dragOffset, moved: false, cardIndex, cardNode }
event.currentTarget.setPointerCapture(event.pointerId)
setDragging(true)
}
const movePointer = (event) => {
if (dragState.current.pointerId === event.pointerId) {
const rect = event.currentTarget.getBoundingClientRect()
const delta = ((event.clientX - dragState.current.startX) / rect.width) * 100
// 5px 是点击/拖动阈值:以内仍打开档案,超过才移动轨道并取消 Hover。
if (Math.abs(event.clientX - dragState.current.startX) > 5) {
dragState.current.moved = true
setHovered(null)
}
setDragOffset(Math.max(minDragOffset, Math.min(0, dragState.current.startOffset + delta)))
return
}
trackPointer(event)
}
const finishDrag = (event) => {
if (dragState.current.pointerId !== event.pointerId) return
const { moved, cardIndex, cardNode } = dragState.current
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
dragState.current.pointerId = null
setDragging(false)
if (moved) {
setHovered(null)
return
}
// Pointer Capture 下不能依赖 button 的原生 click;轻点由这里明确打开最初按下的卡片。
if (Number.isInteger(cardIndex) && cardNode) {
suppressClick.current = true
openFile(files[cardIndex], cardIndex, cardNode)
window.setTimeout(() => { suppressClick.current = false }, 0)
}
}
const cancelDrag = (event) => {
if (dragState.current.pointerId !== event.pointerId) return
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
dragState.current = { pointerId: null, startX: 0, startOffset: dragOffset, moved: false, cardIndex: null, cardNode: null }
setDragging(false)
setHovered(null)
}
useEffect(() => {
const onKeyDown = (event) => event.key === 'Escape' && active && closeFile()
window.addEventListener('keydown', onKeyDown)
document.body.classList.toggle('locked', Boolean(active))
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.classList.remove('locked')
}
}, [active])
useEffect(() => () => window.clearTimeout(wheelTimer.current), [])
useEffect(() => {
const wheelFiles = (event) => {
const stack = stackRef.current
if (!stack || active) return
const rect = stack.getBoundingClientRect()
const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
if (!rawDelta) return
event.preventDefault()
// 首页任意位置都接管滚轮;同时兼容普通鼠标纵向滚动和触控板横滑。
const delta = event.deltaMode === 1 ? rawDelta * 16 : event.deltaMode === 2 ? rawDelta * rect.width : rawDelta
setDragOffset((current) => Math.max(minDragOffset, Math.min(0, current - (delta / rect.width) * 100)))
setHovered(null)
setWheeling(true)
window.clearTimeout(wheelTimer.current)
wheelTimer.current = window.setTimeout(() => setWheeling(false), 140)
}
window.addEventListener('wheel', wheelFiles, { passive: false })
return () => window.removeEventListener('wheel', wheelFiles)
}, [active, minDragOffset])
return (
!active && !dragging && setHovered(null)}
>
— ARCHIVE INDEX · 01–{String(files.length).padStart(2, '0')}
世界档案
世界观 · 人物与故事结构
记录那些半梦半醒的世界、人物关系、叙事结构与设计碎片。选择一份档案,查看其内容。
↔ HOVER / DRAG / SCROLL
!active && !dragging && setHovered(null)}
>
{files.map((file, index) => )}
{active && }
)
}
createRoot(document.getElementById('root')).render()