196 lines
9.8 KiB
React
196 lines
9.8 KiB
React
'use client';
|
||
|
||
import { motion } from 'motion/react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { apiRequest, ensureLogin } from '../lib/api';
|
||
|
||
const THEMES = {
|
||
celebration: ['#ff6038', '#fffaf4', '#25211f', '#ffe568'],
|
||
editorial: ['#f3eadf', '#201d1b', '#ffffff', '#285ee8'],
|
||
notebook: ['#c9f06b', '#1e2810', '#ff72a7', '#ffffff']
|
||
};
|
||
const TOOLS = [
|
||
['filter', '滤镜'], ['text', '文字'], ['sticker', '贴纸'], ['crop', '裁剪'],
|
||
['adjust', '调整'], ['layout', '布局'], ['brush', '画笔']
|
||
];
|
||
const VIEWS = [['overview', '总览'], ['left', '左半区'], ['right', '右半区'], ['top16', '16 强'], ['top8', '8 强'], ['final', '决赛']];
|
||
|
||
export default function PosterEditor({ runId }) {
|
||
const router = useRouter();
|
||
const canvasRef = useRef(null);
|
||
const [result, setResult] = useState(null);
|
||
const [error, setError] = useState('');
|
||
const [theme, setTheme] = useState('celebration');
|
||
const [layout, setLayout] = useState('symmetric');
|
||
const [view, setView] = useState('overview');
|
||
const [tool, setTool] = useState('filter');
|
||
const [sticker, setSticker] = useState(true);
|
||
const [caption, setCaption] = useState('');
|
||
const [background, setBackground] = useState(null);
|
||
const [adjust, setAdjust] = useState({ brightness: 100, contrast: 100, saturate: 100 });
|
||
|
||
useEffect(() => {
|
||
async function loadResult() {
|
||
const cached = sessionStorage.getItem('pk_last_result');
|
||
if (cached) {
|
||
const parsed = JSON.parse(cached);
|
||
if (parsed.id === runId) return parsed;
|
||
}
|
||
await ensureLogin();
|
||
return apiRequest(`/api/v1/me/results/${runId}`);
|
||
}
|
||
loadResult().then(setResult).catch(error => setError(error.message));
|
||
}, [runId]);
|
||
|
||
const draw = useCallback(() => {
|
||
if (!result || !canvasRef.current) return;
|
||
const canvas = canvasRef.current;
|
||
const ctx = canvas.getContext('2d');
|
||
const colors = THEMES[theme];
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = colors[0];
|
||
ctx.fillRect(0, 0, 750, 1000);
|
||
if (background) {
|
||
ctx.save();
|
||
ctx.filter = `brightness(${adjust.brightness}%) contrast(${adjust.contrast}%) saturate(${adjust.saturate}%)`;
|
||
coverImage(ctx, background, 0, 0, 750, 1000);
|
||
ctx.restore();
|
||
ctx.fillStyle = `${colors[0]}d8`;
|
||
ctx.fillRect(0, 0, 750, 1000);
|
||
}
|
||
ctx.fillStyle = colors[1];
|
||
ctx.font = '700 24px system-ui';
|
||
ctx.fillText('PK 研究所 / BRACKET', 42, 62);
|
||
ctx.font = '900 48px system-ui';
|
||
ctx.fillText(result.test_title, 42, 125);
|
||
ctx.fillStyle = colors[2];
|
||
ctx.fillRect(28, 165, 694, 690);
|
||
drawBracket(ctx, result, colors, layout, view);
|
||
if (caption) {
|
||
ctx.fillStyle = colors[3];
|
||
roundRect(ctx, 42, 870, Math.min(650, 50 + caption.length * 34), 52, 26);
|
||
ctx.fill();
|
||
ctx.fillStyle = colors[2];
|
||
ctx.font = '800 25px system-ui';
|
||
ctx.fillText(caption.slice(0, 20), 67, 904);
|
||
} else {
|
||
ctx.fillStyle = colors[1];
|
||
ctx.font = '800 25px system-ui';
|
||
ctx.fillText(`冠军 ${result.champion_name}`, 42, 910);
|
||
}
|
||
if (sticker) {
|
||
ctx.fillStyle = colors[3];
|
||
ctx.beginPath(); ctx.arc(660, 100, 48, 0, Math.PI * 2); ctx.fill();
|
||
ctx.fillStyle = colors[2]; ctx.font = '900 22px system-ui'; ctx.fillText('WIN', 635, 108);
|
||
}
|
||
ctx.fillStyle = colors[1]; ctx.font = '18px system-ui';
|
||
ctx.fillText('保存分享图,看看朋友的晋级路线', 42, 962);
|
||
}, [adjust, background, caption, layout, result, sticker, theme, view]);
|
||
|
||
useEffect(() => { draw(); }, [draw]);
|
||
|
||
function pickBackground(event) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
const image = new Image();
|
||
image.onload = () => setBackground(image);
|
||
image.src = URL.createObjectURL(file);
|
||
}
|
||
|
||
function save() {
|
||
const link = document.createElement('a');
|
||
link.download = `我的PK冠军-${result.champion_name}.png`;
|
||
link.href = canvasRef.current.toDataURL('image/png');
|
||
link.click();
|
||
}
|
||
|
||
if (error) return <main className="arenaState"><h1>结果读取失败</h1><p>{error}</p><button onClick={() => router.push('/')}>返回首页</button></main>;
|
||
if (!result) return <main className="arenaState">正在打开编辑器…</main>;
|
||
|
||
return (
|
||
<main className="editorPage">
|
||
<header className="editorHeader">
|
||
<button onClick={() => router.push('/me')}>←</button>
|
||
<div><strong>分享图编辑器</strong><small>{result.test_title} · {result.champion_name}</small></div>
|
||
<button className="exportButton" onClick={save}>导出</button>
|
||
</header>
|
||
<section className="editorWorkspace">
|
||
<canvas ref={canvasRef} width="750" height="1000" />
|
||
<aside className="editorControls">
|
||
<div className="scrollRow compact">
|
||
<button className={layout === 'symmetric' ? 'active' : ''} onClick={() => setLayout('symmetric')}>对称图</button>
|
||
<button className={layout === 'single' ? 'active' : ''} onClick={() => setLayout('single')}>单边图</button>
|
||
{VIEWS.map(item => <button key={item[0]} className={view === item[0] ? 'active' : ''} onClick={() => setView(item[0])}>{item[1]}</button>)}
|
||
</div>
|
||
<div className="mediaRow">
|
||
<span className="bracketThumb">晋级图</span>
|
||
<label className="addMedia">+<small>添加底图</small><input type="file" accept="image/*" onChange={pickBackground} /></label>
|
||
</div>
|
||
<div className="toolRow">
|
||
{TOOLS.map((item, index) => <button key={item[0]} className={tool === item[0] ? 'active' : ''} onClick={() => setTool(item[0])}><i>{index + 1}</i><span>{item[1]}</span></button>)}
|
||
</div>
|
||
<motion.div key={tool} className="toolPanel" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
|
||
{tool === 'filter' ? <><button onClick={() => setTheme('celebration')}>庆祝派对</button><button onClick={() => setTheme('editorial')}>杂志封面</button><button onClick={() => setTheme('notebook')}>贴纸手账</button></> : null}
|
||
{tool === 'text' ? <input value={caption} maxLength={20} onChange={event => setCaption(event.target.value)} placeholder="输入花字标题" /> : null}
|
||
{tool === 'sticker' ? <button onClick={() => setSticker(value => !value)}>冠军贴纸:{sticker ? '开启' : '关闭'}</button> : null}
|
||
{tool === 'adjust' ? <AdjustControls value={adjust} onChange={setAdjust} /> : null}
|
||
{['crop', 'layout', 'brush'].includes(tool) ? <p>该工具由 PosterDocument 图层绑定驱动,可继续从素材服务加载预设。</p> : null}
|
||
</motion.div>
|
||
</aside>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function AdjustControls({ value, onChange }) {
|
||
return <div className="adjustControls">{Object.entries(value).map(([name, amount]) => <label key={name}><span>{name}</span><input type="range" min="50" max="150" value={amount} onChange={event => onChange({ ...value, [name]: Number(event.target.value) })} /></label>)}</div>;
|
||
}
|
||
|
||
function coverImage(ctx, image, x, y, width, height) {
|
||
const ratio = Math.max(width / image.width, height / image.height);
|
||
const drawWidth = image.width * ratio, drawHeight = image.height * ratio;
|
||
ctx.drawImage(image, x + (width - drawWidth) / 2, y + (height - drawHeight) / 2, drawWidth, drawHeight);
|
||
}
|
||
|
||
function roundRect(ctx, x, y, width, height, radius) {
|
||
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); return; }
|
||
ctx.beginPath(); ctx.rect(x, y, width, height);
|
||
}
|
||
|
||
function drawBracket(ctx, result, colors, layout, view) {
|
||
const decisions = result.decisions || [], names = result.candidate_names || {};
|
||
if (!decisions.length) {
|
||
ctx.fillStyle = colors[1]; ctx.font = '900 72px system-ui'; ctx.fillText(result.champion_name, 70, 500); return;
|
||
}
|
||
const maxRound = Math.max(...decisions.map(item => item.round));
|
||
const minRound = view === 'final' ? maxRound : view === 'top8' ? Math.max(1, maxRound - 2) : view === 'top16' ? Math.max(1, maxRound - 3) : 1;
|
||
let visible = decisions.filter(item => item.round >= minRound);
|
||
if (view === 'left' || view === 'right') visible = visible.filter((item, index) => item.round === maxRound || index % 2 === (view === 'left' ? 0 : 1));
|
||
const groups = {}, positions = {};
|
||
visible.forEach(item => (groups[item.round] ||= []).push(item));
|
||
Object.entries(groups).forEach(([roundValue, items]) => {
|
||
const round = Number(roundValue);
|
||
items.forEach((item, index) => {
|
||
const symmetric = layout === 'symmetric';
|
||
const side = symmetric && round < maxRound && index % 2 ? 1 : -1;
|
||
const x = symmetric ? 375 + side * Math.min(260, 78 + (maxRound - round) * 62) : 70 + (round - minRound) * (580 / Math.max(1, maxRound - minRound));
|
||
const y = 230 + (index + 1) * (520 / (items.length + 1));
|
||
positions[`${item.winner_id}:${item.round}`] = { x, y, item };
|
||
});
|
||
});
|
||
ctx.strokeStyle = colors[3]; ctx.lineWidth = 3;
|
||
Object.values(positions).forEach(position => {
|
||
const parentDecision = visible.find(item => item.round > position.item.round && [item.left_id, item.right_id].includes(position.item.winner_id));
|
||
const parent = parentDecision && positions[`${parentDecision.winner_id}:${parentDecision.round}`];
|
||
if (!parent) return;
|
||
ctx.beginPath(); ctx.moveTo(position.x, position.y); ctx.lineTo((position.x + parent.x) / 2, position.y); ctx.lineTo((position.x + parent.x) / 2, parent.y); ctx.lineTo(parent.x, parent.y); ctx.stroke();
|
||
});
|
||
Object.values(positions).forEach(position => {
|
||
const label = names[position.item.winner_id] || result.champion_name;
|
||
ctx.fillStyle = position.item.round === maxRound ? colors[3] : colors[0];
|
||
ctx.fillRect(position.x - 56, position.y - 20, 112, 40);
|
||
ctx.fillStyle = colors[1]; ctx.font = `${label.length > 6 ? 13 : 16}px system-ui`; ctx.fillText(label.slice(0, 8), position.x - 47, position.y + 6);
|
||
});
|
||
}
|