987 lines
38 KiB
TypeScript
987 lines
38 KiB
TypeScript
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 的 transform(GPU 合成),不触发 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)
|
||
|
||
// 拖拽起点取快照需要最新 els;ref 在 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>
|
||
)
|
||
}
|