PersonalWebApplication/Client/src/components/PhotoWallPage.tsx
2026-06-24 21:37:00 +08:00

987 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from 'react'
import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'
/* ════════════════════════════════════════════════════════════════
照片墙 Photo Wall —— 互动式墙面照片展示。
纯前端:照片/便签/装饰皆可拖拽、编辑,状态存 localStorage可导出 PNG。
设计文档见 docs产品设计 v1.0)。本组件是其 React 重写版。
──────────────────────────────────────────────────────────────
性能取舍:拖拽时直接改 DOM 的 transformGPU 合成),不触发 React
重渲染;松手后再一次性 commit 进状态。故拖拽过程中不得 setState。
════════════════════════════════════════════════════════════════ */
interface PhotoWallPageProps {
/** 退出照片墙(清登录态 + 抹掉地址栏 #photo回到导航页 */
onExit: () => void
/** 当前会话 JWT供后续服务端图片接口使用Task 6 正式消费) */
token: string
}
type ElType = 'photo' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle'
interface El {
id: number
type: ElType
x: number
y: number
rot: number
scale: number
z: number
// photo
src?: string
w?: number
h?: number
caption?: string
fix?: 'tape' | 'pin' | 'tape2'
fixColor?: string
fixRot?: number
// note
text?: string
color?: string
/** 服务器图片 id仅 type==='photo'),也是 localStorage 排版的 key */
photoId?: string
// sticker
bg?: string
// doodle
path?: string
}
const STORAGE_KEY = 'photo-wall-state'
const HISTORY_LIMIT = 30
const H2C_SRC = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'
const BACKGROUNDS = [
{ cls: 'pw-bg-cork', name: '软木板' },
{ cls: 'pw-bg-blackboard', name: '黑板' },
{ cls: 'pw-bg-wall', name: '白墙' },
{ cls: 'pw-bg-kraft', name: '牛皮纸' },
]
const PIN_COLORS = ['#E74C3C', '#3498DB', '#2ECC71', '#F1C40F', '#9B59B6', '#E67E22', '#1ABC9C', '#FF6FAE']
const NOTE_COLORS = ['#FFF9C4', '#C8E6C9', '#BBDEFB', '#F8BBD9', '#FFE0B2']
const TAPE_COLORS = [
'rgba(255,224,102,0.55)', 'rgba(120,190,255,0.55)', 'rgba(255,120,120,0.55)',
'rgba(130,220,150,0.55)', 'rgba(255,160,205,0.55)', 'rgba(255,190,120,0.55)',
]
const STICKERS = [
'✨ 美好时光', '📸 拍立得', '🌸 春天', '☀️ 夏日', '🍂 秋天', '❄️ 冬日',
'🎉 庆祝', '💕 最爱', '🌙 夜晚', '🏖️ 旅行', '👨‍👩‍👧 家人', '🐾 宠物',
]
const STAMP_TEXTS = ['MEMORIES', 'VINTAGE', '2024', 'PHOTO', 'LOVE', 'TRAVEL']
/* ── 小工具 ─────────────────────────────────────────────────── */
const rand = (a: number, b: number) => Math.random() * (b - a) + a
const randInt = (a: number, b: number) => Math.floor(rand(a, b + 1))
const pick = <T,>(arr: T[]): T => arr[randInt(0, arr.length - 1)]
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
// #rrggbb 调亮/调暗(便签折角取底色更深 15%)。
function shade(hex: string, amt: number) {
if (hex[0] !== '#' || hex.length < 7) return hex
const ch = (i: number) =>
clamp(Math.round(parseInt(hex.slice(i, i + 2), 16) + 255 * amt), 0, 255)
.toString(16)
.padStart(2, '0')
return '#' + ch(1) + ch(3) + ch(5)
}
function transformOf(el: El, extraScale = 1) {
return `translate(${el.x}px, ${el.y}px) rotate(${el.rot}deg) scale(${el.scale * extraScale})`
}
/** 每张照片的本地排版覆盖(按服务器图片 id 存浏览器) */
interface PhotoLayout {
x: number
y: number
rot: number
scale: number
z: number
w: number
caption: string
}
interface SavedState {
bgIndex: number
decorations: El[]
photoLayout: Record<string, PhotoLayout>
}
function loadInitial() {
let decorations: El[] = []
let photoLayout: Record<string, PhotoLayout> = {}
let bgIndex = 0
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const data = JSON.parse(raw) as Partial<SavedState> & { els?: El[] }
if (typeof data.bgIndex === 'number') bgIndex = data.bgIndex
if (Array.isArray(data.decorations)) {
decorations = data.decorations
} else if (Array.isArray(data.els)) {
// 旧结构(照片带 base64 存本地):只保留装饰,丢弃旧照片。
decorations = data.els.filter((e) => e.type !== 'photo')
}
if (data.photoLayout && typeof data.photoLayout === 'object') {
photoLayout = data.photoLayout
}
}
} catch {
/* 损坏的存档当作空墙 */
}
const nextId = decorations.reduce((m, e) => Math.max(m, e.id), 0) + 1
const maxZ = Math.max(10, ...decorations.map((e) => e.z || 10))
return { decorations, photoLayout, bgIndex, nextId, maxZ }
}
export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const initial = useMemo(() => loadInitial(), [])
const [els, setEls] = useState<El[]>(initial.decorations)
// 每张图的本地排版(按服务器图片 id。runtime 维护,保存时连同装饰一起落盘。
const layoutRef = useRef<Record<string, PhotoLayout>>(initial.photoLayout)
// 本组件创建的所有 objectURL卸载时统一释放。
const objUrls = useRef<string[]>([])
const [bgIndex, setBgIndex] = useState(initial.bgIndex)
const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editText, setEditText] = useState('')
const [decoOpen, setDecoOpen] = useState(false)
const [customSticker, setCustomSticker] = useState('')
const [doodleMode, setDoodleMode] = useState(false)
const [dragHint, setDragHint] = useState(false)
const [saveLabel, setSaveLabel] = useState('已保存')
const [toast, setToast] = useState<string | null>(null)
const wallRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef(new Map<number, HTMLDivElement>())
const menuRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const idRef = useRef(initial.nextId)
const zRef = useRef(initial.maxZ)
const elsRef = useRef(els) // 给拖拽起点取快照用(避免闭包旧值)
const undoRef = useRef<string[]>([])
const redoRef = useRef<string[]>([])
const doodleRef = useRef(false)
const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
// 拖拽起点取快照需要最新 elsref 在 effect 里同步,避免渲染期写 ref。
useEffect(() => {
elsRef.current = els
})
const showToast = useCallback((msg: string) => {
setToast(msg)
clearTimeout(toastTimer.current)
toastTimer.current = setTimeout(() => setToast(null), 1800)
}, [])
// 标记“有未保存改动”,由下方防抖 effect 在 1s 后落盘。
const markDirty = useCallback(() => setSaveLabel('保存中…'), [])
/* ── 历史栈 ──────────────────────────────────────────────── */
const pushHistory = useCallback((prev: El[]) => {
undoRef.current.push(JSON.stringify(prev))
if (undoRef.current.length > HISTORY_LIMIT) undoRef.current.shift()
redoRef.current.length = 0
}, [])
// 记一步历史并改状态。离散操作(增删改旋转)都走这里。
const mutate = useCallback(
(updater: (prev: El[]) => El[]) => {
markDirty()
setEls((prev) => {
pushHistory(prev)
return updater(prev)
})
},
[pushHistory, markDirty],
)
const undo = useCallback(() => {
markDirty()
setEls((cur) => {
if (!undoRef.current.length) return cur
redoRef.current.push(JSON.stringify(cur))
return JSON.parse(undoRef.current.pop() as string) as El[]
})
}, [markDirty])
const redo = useCallback(() => {
markDirty()
setEls((cur) => {
if (!redoRef.current.length) return cur
undoRef.current.push(JSON.stringify(cur))
return JSON.parse(redoRef.current.pop() as string) as El[]
})
}, [markDirty])
/* ── 自动保存(防抖 1s──────────────────────────────────────
“保存中…”由各改动入口mutate/undo/redo/拖拽/换背景)打标;
本 effect 仅负责实际落盘并在完成后置“已保存”(均为异步,不级联渲染)。 */
useEffect(() => {
const t = setTimeout(() => {
try {
const decorations = els.filter((e) => e.type !== 'photo')
// 从已知排版起步而非清空:未加载完/取图失败的照片其排版得以保留,
// 已加载或移动过的照片在下面覆盖各自的 key删除时 removeEl 已从 layoutRef 删键)。
const photoLayout: Record<string, PhotoLayout> = { ...layoutRef.current }
for (const e of els) {
if (e.type === 'photo' && e.photoId) {
photoLayout[e.photoId] = {
x: e.x, y: e.y, rot: e.rot, scale: e.scale, z: e.z,
w: e.w ?? 160, caption: e.caption ?? '',
}
}
}
layoutRef.current = photoLayout
localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, decorations, photoLayout }))
setSaveLabel('已保存')
} catch {
setSaveLabel('保存失败')
showToast('保存失败:存储空间可能已满')
}
}, 1000)
return () => clearTimeout(t)
}, [els, bgIndex, showToast])
/* ── 懒加载 html2canvas导出用非阻塞──────────────────── */
useEffect(() => {
if (document.querySelector(`script[src="${H2C_SRC}"]`)) return
const s = document.createElement('script')
s.src = H2C_SRC
s.async = true
document.head.appendChild(s)
}, [])
/* ── 元素创建 ────────────────────────────────────────────── */
const spawnPos = useCallback((spread = 0) => {
const r = wallRef.current?.getBoundingClientRect()
const w = r?.width ?? window.innerWidth
const h = r?.height ?? window.innerHeight
return {
x: rand(w * 0.12, w * 0.7) + rand(-spread, spread),
y: rand(h * 0.12, h * 0.65) + rand(-spread, spread),
}
}, [])
const addEl = useCallback(
(partial: Omit<El, 'id' | 'z'>) => {
const el: El = { ...partial, id: idRef.current++, z: ++zRef.current }
mutate((prev) => [...prev, el])
},
[mutate],
)
// 登录后从服务器拉本用户图片:取字节为 objectURL套用本地排版或自动散落。
useEffect(() => {
if (!token) return
let alive = true
;(async () => {
try {
// 重新加载前清掉旧会话的照片,避免重复堆叠与 objectURL 泄漏
for (const u of objUrls.current) URL.revokeObjectURL(u)
objUrls.current = []
setEls((prev) => prev.filter((e) => e.type !== 'photo'))
const { items } = await listPhotos(token)
for (const item of items) {
let url: string
try {
url = await fetchPhotoObjectUrl(token, item.id)
} catch {
continue
}
if (!alive) {
URL.revokeObjectURL(url)
return
}
objUrls.current.push(url)
const layout = layoutRef.current[item.id]
// 取自然尺寸算宽高比;无本地排版时随机散落。
const dims = await new Promise<{ w: number; h: number }>((resolve) => {
const img = new Image()
img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 })
img.onerror = () => resolve({ w: 1, h: 1 })
img.src = url
})
if (!alive) return
const ratio = dims.h / dims.w
const roll = Math.random()
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
const pos = spawnPos(40)
const base: El = layout
? {
id: idRef.current++, type: 'photo', src: url, photoId: item.id,
w: layout.w, h: Math.round(layout.w * ratio),
x: layout.x, y: layout.y, rot: layout.rot, scale: layout.scale,
z: layout.z, caption: layout.caption,
fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
}
: {
id: idRef.current++, type: 'photo', src: url, photoId: item.id,
w: 170, h: Math.round(170 * ratio),
x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, z: ++zRef.current,
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
}
if (layout) zRef.current = Math.max(zRef.current, layout.z)
setEls((prev) => [...prev, base])
}
} catch {
if (alive) showToast('图片加载失败(请重新登录或检查网络)')
}
})()
return () => {
alive = false
}
// 仅在挂载/换 token 时跑一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
// 卸载时释放所有 objectURL
useEffect(() => {
return () => {
for (const u of objUrls.current) URL.revokeObjectURL(u)
}
}, [])
const makeNote = useCallback(() => {
const pos = spawnPos(20)
addEl({ type: 'note', text: '', color: pick(NOTE_COLORS), x: pos.x, y: pos.y, rot: rand(-6, 6), scale: 1 })
}, [addEl, spawnPos])
const makeTape = useCallback(() => {
const pos = spawnPos(20)
addEl({ type: 'tape', w: randInt(80, 140), color: pick(TAPE_COLORS), x: pos.x, y: pos.y, rot: rand(-20, 20), scale: 1 })
}, [addEl, spawnPos])
const makeSticker = useCallback(
(text: string, bg = '') => {
const pos = spawnPos(20)
addEl({ type: 'sticker', text, bg, x: pos.x, y: pos.y, rot: rand(-8, 8), scale: 1 })
},
[addEl, spawnPos],
)
const makeStamp = useCallback(() => {
const pos = spawnPos(20)
addEl({
type: 'stamp', text: pick(STAMP_TEXTS),
color: Math.random() < 0.5 ? '#C0392B' : '#1A237E',
x: pos.x, y: pos.y, rot: rand(-12, 12), scale: 1,
})
}, [addEl, spawnPos])
/* ── 图片上传(按钮 / 拖入 / 粘贴)─────────────────────────── */
const handleFiles = useCallback(
(files: FileList | File[]) => {
const list = Array.from(files)
const imgs = list.filter((f) => f.type.startsWith('image/'))
if (!imgs.length) {
if (list.some((f) => /heic/i.test(f.type) || /\.heic$/i.test(f.name)))
showToast('HEIC 格式需先转换为 JPG/PNG')
return
}
imgs.forEach((file) => {
if (file.size > 10 * 1024 * 1024) showToast('图片较大,建议压缩后上传')
const reader = new FileReader()
reader.onload = (ev) => {
const src = ev.target?.result as string
// 满 10 张直接拦下
const count = elsRef.current.filter((e) => e.type === 'photo').length
if (count >= 10) {
showToast('每位用户最多 10 张照片')
return
}
uploadPhoto(token, src)
.then((id) => {
const img = new Image()
img.onload = () => {
const w = randInt(140, 200)
const h = Math.round(w * (img.height / img.width))
const pos = spawnPos(40)
const roll = Math.random()
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
addEl({
type: 'photo', src, photoId: id, w, h,
x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1,
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
})
}
img.src = src
})
.catch((err: Error) => {
const reason = err.message as UploadError
showToast(
reason === 'full' ? '每位用户最多 10 张照片'
: reason === 'too-large' ? '图片过大(上限 8MB'
: reason === 'bad-type' ? '不支持的图片格式'
: '上传失败',
)
})
}
reader.readAsDataURL(file)
})
},
[addEl, spawnPos, showToast, token],
)
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
const files: File[] = []
for (const it of items) if (it.type.startsWith('image/')) {
const f = it.getAsFile()
if (f) files.push(f)
}
if (files.length) handleFiles(files)
}
window.addEventListener('paste', onPaste)
return () => window.removeEventListener('paste', onPaste)
}, [handleFiles])
/* ── 拖拽Pointer Events鼠标 + 触摸统一)──────────────────
拖拽中只改 DOM不 setState松手后一次性 commit。 */
const onElPointerDown = useCallback(
(e: ReactPointerEvent, el: El) => {
if (e.button === 2 || doodleRef.current) return
const tgt = e.target as HTMLElement
if (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA') return
e.preventDefault()
const node = nodeRefs.current.get(el.id)
if (!node) return
const startX = e.clientX
const startY = e.clientY
const origX = el.x
const origY = el.y
let curX = origX
let curY = origY
let moved = false
const newZ = ++zRef.current
node.style.zIndex = String(newZ)
node.classList.add('pw-dragging')
const snapshot = JSON.stringify(elsRef.current)
// 触摸长按 = 右键菜单
let longPress: ReturnType<typeof setTimeout> | undefined
if (e.pointerType === 'touch') {
longPress = setTimeout(() => {
cleanup()
node.style.transform = transformOf(el)
setMenu({ x: startX, y: startY, id: el.id })
}, 500)
}
const move = (ev: PointerEvent) => {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
moved = true
clearTimeout(longPress)
}
curX = origX + dx
curY = origY + dy
node.style.transform = transformOf({ ...el, x: curX, y: curY }, 1.02)
}
const cleanup = () => {
clearTimeout(longPress)
node.classList.remove('pw-dragging')
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
const up = () => {
cleanup()
if (moved) {
markDirty()
undoRef.current.push(snapshot)
if (undoRef.current.length > HISTORY_LIMIT) undoRef.current.shift()
redoRef.current.length = 0
setEls((prev) => prev.map((p) => (p.id === el.id ? { ...p, x: curX, y: curY, z: newZ } : p)))
} else {
// 纯点击:仅把层级提到最前(不记历史)
setEls((prev) => prev.map((p) => (p.id === el.id ? { ...p, z: newZ } : p)))
}
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
},
[markDirty],
)
/* ── 右键菜单动作 ────────────────────────────────────────── */
const rotate = (id: number, d: number) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, rot: e.rot + d } : e)))
const scaleEl = (id: number, d: number) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: clamp(e.scale + d, 0.4, 4) } : e)))
const resizePhoto = (id: number, d: number) =>
mutate((p) =>
p.map((e) => {
if (e.id !== id || !e.w || !e.h) return e
const ratio = e.h / e.w
const w = clamp(e.w + d, 80, 600)
return { ...e, w, h: Math.round(w * ratio) }
}),
)
const toFront = (id: number) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, z: ++zRef.current } : e)))
const toBack = (id: number) =>
mutate((p) => {
const minZ = Math.min(...p.map((e) => e.z))
return p.map((e) => (e.id === id ? { ...e, z: minZ - 1 } : e))
})
const recolorNote = (id: number, color: string) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, color } : e)))
const setRotation = (id: number, deg: number) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, rot: deg } : e)))
const setScale = (id: number, s: number) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: s } : e)))
const removeEl = (id: number) => {
const target = elsRef.current.find((e) => e.id === id)
if (target?.type === 'photo' && target.photoId) {
deletePhoto(token, target.photoId).catch(() => {})
delete layoutRef.current[target.photoId]
if (target.src?.startsWith('blob:')) {
URL.revokeObjectURL(target.src)
objUrls.current = objUrls.current.filter((u) => u !== target.src)
}
}
mutate((p) => p.filter((e) => e.id !== id))
nodeRefs.current.delete(id)
setMenu(null)
}
// 菜单贴边自动翻转,避免超出视口
useLayoutEffect(() => {
if (!menu || !menuRef.current) return
const m = menuRef.current
const mw = m.offsetWidth
const mh = m.offsetHeight
let px = menu.x
let py = menu.y
if (px + mw > window.innerWidth) px = menu.x - mw
if (py + mh > window.innerHeight) py = menu.y - mh
m.style.left = Math.max(4, px) + 'px'
m.style.top = Math.max(4, py) + 'px'
}, [menu])
// 点空白处关闭菜单/下拉
useEffect(() => {
const onDown = (e: globalThis.PointerEvent) => {
const t = e.target as HTMLElement
if (!t.closest('.pw-menu')) setMenu(null)
if (!t.closest('.pw-deco')) setDecoOpen(false)
}
window.addEventListener('pointerdown', onDown)
return () => window.removeEventListener('pointerdown', onDown)
}, [])
/* ── 双击编辑(照片标注 / 便签)──────────────────────────── */
const startEdit = (el: El) => {
setEditText(el.type === 'photo' ? el.caption ?? '' : el.text ?? '')
setEditingId(el.id)
}
const commitEdit = (id: number) => {
const val = editText
mutate((p) =>
p.map((e) =>
e.id === id ? (e.type === 'photo' ? { ...e, caption: val.trim() } : { ...e, text: val }) : e,
),
)
setEditingId(null)
}
/* ── 手绘箭头:进入模式后在画布上拖动绘制 ──────────────────── */
const startDoodle = () => {
doodleRef.current = true
setDoodleMode(true)
setDecoOpen(false)
showToast('在画布上按住拖动,绘制一条箭头')
}
const onWallPointerDown = (e: ReactPointerEvent) => {
if (!doodleRef.current) return
const t = e.target as HTMLElement
if (t !== wallRef.current && !t.classList.contains('pw-drophint')) return
const r = wallRef.current!.getBoundingClientRect()
const sx = e.clientX - r.left
const sy = e.clientY - r.top
let ex = sx
let ey = sy
const move = (ev: PointerEvent) => {
ex = ev.clientX - r.left
ey = ev.clientY - r.top
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
doodleRef.current = false
setDoodleMode(false)
if (Math.hypot(ex - sx, ey - sy) < 12) return
const pad = 12
const minX = Math.min(sx, ex) - pad
const minY = Math.min(sy, ey) - pad
const w = Math.abs(ex - sx) + pad * 2
const h = Math.abs(ey - sy) + pad * 2
const x1 = sx - minX
const y1 = sy - minY
const x2 = ex - minX
const y2 = ey - minY
const mx = (x1 + x2) / 2 + (y2 - y1) * 0.18
const my = (y1 + y2) / 2 - (x2 - x1) * 0.18
addEl({
type: 'doodle', x: minX, y: minY, w, h,
path: `M${x1},${y1} Q${mx},${my} ${x2},${y2}`,
color: Math.random() < 0.5 ? '#E57373' : '#42A5F5',
rot: 0, scale: 1,
})
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
/* ── 拖入图片 ────────────────────────────────────────────── */
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
setDragHint(true)
}
const onDragLeave = (e: React.DragEvent) => {
if (wallRef.current?.contains(e.relatedTarget as Node)) return
setDragHint(false)
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragHint(false)
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files)
}
/* ── 清空 / 背景 / 导出 ──────────────────────────────────── */
const clearWall = () => {
if (!els.length) return
if (confirm('确定清空整面照片墙吗?')) {
mutate(() => [])
nodeRefs.current.clear()
}
}
const cycleBg = () => { markDirty(); setBgIndex((i) => (i + 1) % BACKGROUNDS.length) }
const exportImage = () => {
type H2C = (el: HTMLElement, opts: Record<string, unknown>) => Promise<HTMLCanvasElement>
const h2c = (window as unknown as { html2canvas?: H2C }).html2canvas
if (!h2c || !wallRef.current) {
showToast('导出库加载中,请稍候重试')
return
}
setMenu(null)
showToast('正在生成图片…')
h2c(wallRef.current, { backgroundColor: null, scale: window.devicePixelRatio || 1, useCORS: true })
.then((canvas) => {
const d = new Date()
const p = (n: number) => String(n).padStart(2, '0')
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
const a = document.createElement('a')
a.download = `photo-wall-${ts}.png`
a.href = canvas.toDataURL('image/png')
a.click()
showToast('已导出 PNG')
})
.catch(() => showToast('导出失败'))
}
/* ── 键盘快捷键 ──────────────────────────────────────────── */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (document.activeElement as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
const mod = e.ctrlKey || e.metaKey
const k = e.key.toLowerCase()
if (mod && k === 'z' && !e.shiftKey) { e.preventDefault(); undo() }
else if (mod && (k === 'y' || (k === 'z' && e.shiftKey))) { e.preventDefault(); redo() }
else if (e.key === 'Escape') { setMenu(null); doodleRef.current = false; setDoodleMode(false) }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [undo, redo])
/* ── 渲染单个元素 ────────────────────────────────────────── */
const setNodeRef = (id: number) => (n: HTMLDivElement | null) => {
if (n) nodeRefs.current.set(id, n)
else nodeRefs.current.delete(id)
}
function renderInner(el: El) {
switch (el.type) {
case 'photo':
return (
<>
<div className="pw-imgwrap" style={{ height: el.h }}>
<img src={el.src} alt="" />
</div>
{editingId === el.id ? (
<input
className="pw-caption-input"
autoFocus
value={editText}
placeholder="写点什么…"
onChange={(e) => setEditText(e.target.value)}
onBlur={() => commitEdit(el.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); commitEdit(el.id) }
e.stopPropagation()
}}
/>
) : (
<div className="pw-caption">{el.caption}</div>
)}
{el.fix === 'pin' && <div className="pw-fix-pin" style={{ background: el.fixColor }} />}
{el.fix === 'tape2' && (
<>
<div className="pw-fix-tape pw-left" />
<div className="pw-fix-tape pw-right" />
</>
)}
{el.fix === 'tape' && (
<div className="pw-fix-tape" style={{ transform: `translateX(-50%) rotate(${el.fixRot}deg)` }} />
)}
</>
)
case 'note':
return (
<>
{editingId === el.id ? (
<textarea
className="pw-note-input"
autoFocus
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={() => commitEdit(el.id)}
onKeyDown={(e) => e.stopPropagation()}
/>
) : (
<div style={{ opacity: el.text ? 1 : 0.45 }}>{el.text || '双击编辑…'}</div>
)}
<div
className="pw-note-fold"
style={{ borderWidth: '0 0 20px 20px', borderColor: `transparent transparent ${shade(el.color || '#fff', -0.15)} transparent` }}
/>
</>
)
case 'stamp':
return (
<svg width="110" height="110" viewBox="0 0 110 110" style={{ opacity: 0.75 }}>
<circle cx="55" cy="55" r="50" fill="none" stroke={el.color} strokeWidth="4" />
<circle cx="55" cy="55" r="40" fill="none" stroke={el.color} strokeWidth="1.5" />
<text x="55" y="61" textAnchor="middle" fill={el.color} fontFamily="var(--font-sans)" fontSize="15" fontWeight="700" letterSpacing="2">
{el.text}
</text>
</svg>
)
case 'doodle':
return (
<svg width={el.w} height={el.h} viewBox={`0 0 ${el.w} ${el.h}`}>
<defs>
<marker id={`pw-ah-${el.id}`} markerWidth="10" markerHeight="10" refX="7" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6 Z" fill={el.color} />
</marker>
</defs>
<path d={el.path} fill="none" stroke={el.color} strokeWidth="3" strokeLinecap="round" markerEnd={`url(#pw-ah-${el.id})`} />
</svg>
)
case 'sticker':
return el.text
case 'tape':
return null
}
}
function elClass(el: El) {
const base = 'pw-el'
const byType: Record<ElType, string> = {
photo: 'pw-photo', note: 'pw-note', tape: 'pw-tape',
sticker: 'pw-sticker', stamp: 'pw-stamp', doodle: 'pw-doodle',
}
return `${base} ${byType[el.type]}`
}
function elStyle(el: El): CSSProperties {
const s: CSSProperties = { transform: transformOf(el), zIndex: el.z }
if (el.type === 'photo') s.width = el.w
if (el.type === 'tape') {
s.width = el.w
s.background = `repeating-linear-gradient(90deg, rgba(255,255,255,0.2) 0 2px, transparent 2px 4px), ${el.color}`
}
if (el.type === 'note') s.background = el.color
if (el.type === 'sticker' && el.bg) s.background = el.bg
if (el.type === 'doodle') { s.width = el.w; s.height = el.h }
return s
}
const menuEl = menu ? els.find((e) => e.id === menu.id) : null
return (
<div className="pw-root">
{/* ── 工具栏 ── */}
<div className="pw-toolbar">
<span className="pw-logo">📸 Photo Wall</span>
<button className="pw-tbtn" onClick={() => fileRef.current?.click()}></button>
<button className="pw-tbtn" onClick={makeNote}>便</button>
<div className="pw-deco" style={{ position: 'relative' }}>
<button
className="pw-tbtn"
onClick={(e) => { e.stopPropagation(); setDecoOpen((o) => !o) }}
>
</button>
{decoOpen && (
<div className="pw-pop" style={{ position: 'absolute', top: 'calc(100% + 6px)', left: 0, minWidth: 200, zIndex: 31 }}>
<div className="pw-item" onClick={() => { makeTape(); setDecoOpen(false) }}>🎀 </div>
<div className="pw-sep" />
<div className="pw-grid">
{STICKERS.map((s) => (
<div key={s} className="pw-item" onClick={() => { makeSticker(s); setDecoOpen(false) }}>{s}</div>
))}
</div>
<div className="pw-rowin">
<input
value={customSticker}
placeholder="自定义贴纸文字…"
maxLength={20}
onChange={(e) => setCustomSticker(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && customSticker.trim()) {
makeSticker(customSticker.trim()); setCustomSticker(''); setDecoOpen(false)
}
}}
/>
<button
onClick={() => {
if (customSticker.trim()) { makeSticker(customSticker.trim()); setCustomSticker(''); setDecoOpen(false) }
}}
>
</button>
</div>
<div className="pw-sep" />
<div className="pw-item" onClick={() => { makeStamp(); setDecoOpen(false) }}>🔖 </div>
<div className="pw-item" onClick={startDoodle}> </div>
</div>
)}
</div>
<button className="pw-tbtn pw-danger" onClick={clearWall}></button>
<span style={{ flex: 1 }} />
<span className="pw-save">{saveLabel}</span>
<button className="pw-tbtn" onClick={exportImage}></button>
<button className="pw-tbtn" onClick={cycleBg}>{BACKGROUNDS[bgIndex].name}</button>
<button className="pw-tbtn pw-danger" onClick={onExit}>退</button>
</div>
{/* ── 画布 ── */}
<div
ref={wallRef}
className={`pw-wall ${BACKGROUNDS[bgIndex].cls}`}
style={doodleMode ? { cursor: 'crosshair' } : undefined}
onPointerDown={onWallPointerDown}
onDragOver={onDragOver}
onDragEnter={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onContextMenu={(e) => { if (e.target === wallRef.current) e.preventDefault() }}
>
{els.map((el) => (
<div
key={el.id}
ref={setNodeRef(el.id)}
className={elClass(el)}
style={elStyle(el)}
onPointerDown={(e) => onElPointerDown(e, el)}
onDoubleClick={(e) => {
if (el.type === 'photo' || el.type === 'note') { e.stopPropagation(); startEdit(el) }
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, id: el.id })
}}
>
{renderInner(el)}
</div>
))}
{dragHint && <div className="pw-drophint"></div>}
</div>
{/* ── 右键菜单 ── */}
{menu && menuEl && (
<div ref={menuRef} className="pw-menu pw-pop" style={{ left: menu.x, top: menu.y, minWidth: 170 }}>
{menuEl.type !== 'doodle' && (
<>
<div className="pw-item" onClick={() => { rotate(menu.id, 15); setMenu(null) }}> +15°</div>
<div className="pw-item" onClick={() => { rotate(menu.id, -15); setMenu(null) }}> -15°</div>
{menuEl.type === 'photo' ? (
<>
<div className="pw-item" onClick={() => { resizePhoto(menu.id, 30); setMenu(null) }}></div>
<div className="pw-item" onClick={() => { resizePhoto(menu.id, -30); setMenu(null) }}></div>
</>
) : (
<>
<div className="pw-item" onClick={() => { scaleEl(menu.id, 0.25); setMenu(null) }}></div>
<div className="pw-item" onClick={() => { scaleEl(menu.id, -0.25); setMenu(null) }}></div>
</>
)}
<div className="pw-sep" />
</>
)}
<div className="pw-item" onClick={() => { toFront(menu.id); setMenu(null) }}></div>
<div className="pw-item" onClick={() => { toBack(menu.id); setMenu(null) }}></div>
{menuEl.type === 'note' && (
<>
<div className="pw-sep" />
<div className="pw-colorrow">
{NOTE_COLORS.map((c) => (
<div
key={c}
className="pw-sw"
style={{ background: c }}
onClick={() => { recolorNote(menu.id, c); setMenu(null) }}
/>
))}
</div>
</>
)}
<div className="pw-sep" />
<div className="pw-preset">
<span className="pw-preset-label"></span>
{[0, 15, 30, 45, 90].map((d) => (
<button key={d} className="pw-chip" onClick={() => { setRotation(menu.id, d); setMenu(null) }}>{d}°</button>
))}
</div>
<div className="pw-preset">
<span className="pw-preset-label"></span>
{[0.5, 0.75, 1, 1.5, 2].map((s) => (
<button key={s} className="pw-chip" onClick={() => { setScale(menu.id, s); setMenu(null) }}>{s}×</button>
))}
</div>
<div className="pw-sep" />
<div className="pw-item pw-danger" onClick={() => removeEl(menu.id)}>
<span></span>
<span className="pw-kbd">Del</span>
</div>
</div>
)}
{toast && <div className="pw-toast">{toast}</div>}
<input ref={fileRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={(e) => { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }} />
</div>
)
}