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 = (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 } function loadInitial() { let decorations: El[] = [] let photoLayout: Record = {} let bgIndex = 0 try { const raw = localStorage.getItem(STORAGE_KEY) if (raw) { const data = JSON.parse(raw) as Partial & { 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(initial.decorations) // 每张图的本地排版(按服务器图片 id)。runtime 维护,保存时连同装饰一起落盘。 const layoutRef = useRef>(initial.photoLayout) // 本组件创建的所有 objectURL,卸载时统一释放。 const objUrls = useRef([]) const [bgIndex, setBgIndex] = useState(initial.bgIndex) const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null) const [editingId, setEditingId] = useState(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(null) const wallRef = useRef(null) const nodeRefs = useRef(new Map()) const menuRef = useRef(null) const fileRef = useRef(null) const idRef = useRef(initial.nextId) const zRef = useRef(initial.maxZ) const elsRef = useRef(els) // 给拖拽起点取快照用(避免闭包旧值) const undoRef = useRef([]) const redoRef = useRef([]) const doodleRef = useRef(false) const toastTimer = useRef | 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 = { ...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) => { 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 | 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) => Promise 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 ( <>
{editingId === el.id ? ( setEditText(e.target.value)} onBlur={() => commitEdit(el.id)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEdit(el.id) } e.stopPropagation() }} /> ) : (
{el.caption}
)} {el.fix === 'pin' &&
} {el.fix === 'tape2' && ( <>
)} {el.fix === 'tape' && (
)} ) case 'note': return ( <> {editingId === el.id ? (