/* ============================================================ * app.jsx — Main app shell, views, AI panel, export, canvas * ============================================================ */ const { useState, useEffect, useRef, useCallback, useMemo } = React; // ---- Default strip config ---- const DEFAULT_STRIP = { text: "未来可期", vertical: true, fontId: "xing", fontScale: 1, inkOverride: null, paperId: "cream", textureId: "cloud", shape: "rect-v", showSeal: true, sealText: "福", sealPosition: "bottom", sealRound: false, showBorder: false, borderStyle: "single", showGoldFleck: false, shadow: "soft", paperWear: false, rotate: 0, mount: "felt", }; // Curated templates const TEMPLATES = [ { name: "丹朱新岁", text: "未来可期", paperId: "dan", textureId: "wave", fontId: "kai", shape: "rect-v", sealText: "福", inkOverride: "#1a1612" }, { name: "宣纸寒梅", text: "诸事顺遂", paperId: "cream", textureId: "blossom", fontId: "xing", shape: "rect-v", sealText: "吉" }, { name: "藤黄洒金", text: "马上有钱", paperId: "saffron", textureId: "gold", fontId: "kai", shape: "rect-v", sealText: "财", showGoldFleck: true }, { name: "胭脂祥云", text: "诸事大吉", paperId: "plum", textureId: "cloud", fontId: "xing", shape: "rect-v", sealText: "喜" }, { name: "青瓷顺遂", text: "顺遂安康", paperId: "celadon", textureId: "wave", fontId: "kai", shape: "rect-v", sealText: "安" }, { name: "金粉如意", text: "如意", paperId: "gold", textureId: "kikko", fontId: "li", shape: "square", sealText: "心", sealPosition: "br" }, { name: "墨色金光", text: "暴富", paperId: "ink", textureId: "gold", fontId: "zhuan", shape: "rect-v", sealText: "财", showGoldFleck: true }, { name: "天青福临", text: "福", paperId: "twilight", textureId: "fibers", fontId: "kai", shape: "diamond", sealText: "" , showSeal: false, fontScale: 1.4 }, { name: "正红进财", text: "招财进宝", paperId: "vermilion", textureId: "gold", fontId: "kai", shape: "rect-v", sealText: "财", inkOverride: "#d4b56a", showGoldFleck: true }, { name: "石绿春风", text: "春风得意", paperId: "jade", textureId: "bamboo", fontId: "xing", shape: "rect-v", sealText: "乐" }, { name: "枯叶常安", text: "常安", paperId: "sand", textureId: "fibers", fontId: "li", shape: "square", sealText: "福", sealPosition: "br" }, { name: "雪白闲心", text: "见自己", paperId: "snow", textureId: "fibers", fontId: "xing", shape: "rect-v", sealText: "心" }, ]; // AI prompt presets const AI_PRESETS = [ "新春纳福", "事业顺利", "考试上岸", "心想事成", "暴富发财", "平安喜乐", "桃花朵朵", "出行顺利", "求职成功", "祛病康健", ]; // Helper: render strip fitted inside a parent box. function FittedStrip({ s, seed = 1 }) { const shapeDef = SHAPES[s.shape || "rect-v"]; const isTall = shapeDef.ar < 1; const sizerStyle = isTall ? { height: "94%", aspectRatio: shapeDef.ar } : { width: shapeDef.ar > 2 ? "94%" : "70%", aspectRatio: shapeDef.ar }; return (
); } // =========================================================== // Topbar // =========================================================== function Topbar({ view, setView, onOpenApiKey, onExport, apiKey }) { const brand = window.__BRAND__ || { name: "墨笺", seal: "墨", tag: "MÒ · JIĀN" }; const tabs = [ { id: "edit", name: "编辑" }, { id: "ai", name: "AI 生成" }, { id: "canvas", name: "拼贴" }, { id: "tpl", name: "纸条谱" }, ]; return (
{brand.seal} {brand.name} {brand.tag}
{tabs.map(t => ( ))}
); } // =========================================================== // Stage — shared preview area // =========================================================== function PreviewStage({ strip, stageRef, showHint, tweaks }) { // Determine mount-canvas aspect class based on mount const mountDef = MOUNTS.find(m => m.id === strip.mount) || MOUNTS[0]; let aspectClass = ""; if (mountDef.shape === "tall") aspectClass = "tall"; else if (mountDef.shape === "wide") aspectClass = "wide"; else if (mountDef.shape === "square") aspectClass = "square"; // Strip sizing — by shape aspect ratio, fit inside canvas const shapeDef = SHAPES[strip.shape]; // For tall (ar<1), size by height %. For wide (ar>1), size by width %. const isTall = shapeDef.ar < 1; const sizerStyle = isTall ? { height: "78%", aspectRatio: shapeDef.ar } : { width: shapeDef.ar > 2 ? "78%" : "55%", aspectRatio: shapeDef.ar }; return (
预览 · {SHAPES[strip.shape].name} {PAPER_COLORS.find(p => p.id === strip.paperId)?.name} {FONTS.find(f => f.id === strip.fontId)?.name} {PAPER_TEXTURES.find(t => t.id === strip.textureId)?.name} {MOUNTS.find(m => m.id === strip.mount)?.name}
{showHint || "笺纸轻提,墨字落定 · 调整左侧任意一项,实时同步"} {strip.text.length} 字
); } // =========================================================== // Editor view — left controls + center stage + right actions // =========================================================== function EditView({ strip, set, onAddToCanvas, onSaveTemplate, onExport, onCopy }) { const stageRef = useRef(null); return (
操 作
速 选
{TEMPLATES.slice(0, 6).map((t, i) => ( ))}
); } // =========================================================== // AI view // =========================================================== function AiView({ strip, set, apiKey, onOpenApiKey, library, addToLibrary }) { const [mood, setMood] = useState(""); const [styleHint, setStyleHint] = useState("禅意东方,简素干净"); const [count, setCount] = useState(3); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [err, setErr] = useState(""); const generate = useCallback(async () => { setErr(""); setLoading(true); try { // Use built-in claude.complete if no API key, otherwise call user-supplied API. const prompt = `你是中国传统书法纸条设计师。请根据用户的心情或主题,生成 ${count} 张吉祥纸条建议。 用户主题: ${mood || "随意"} 风格偏好: ${styleHint} 每张纸条返回以下 JSON 字段(严格的 JSON 数组,无任何额外文本): - text: 4字以内的吉祥语,简体 - paperId: 从这些里选: cream snow rice kraft dan vermilion ochre saffron celadon jade indigo twilight plum lilac sand moss ink gold - textureId: 从这些里选: plain cloud gold wave kikko bamboo blossom fibers - fontId: 从这些里选: kai song xing cao caoZhi li zhuan shou maocao - shape: 从这些里选: rect-v rect-h square diamond circle coin gourd flower pouch tall - sealText: 1 个汉字 - name: 一个 4 字以内的纸条诗意名称 例如: [ {"text":"未来可期","paperId":"dan","textureId":"wave","fontId":"kai","shape":"rect-v","sealText":"福","name":"丹朱新岁"}, ... ]`; let text; if (apiKey && apiKey.provider) { // user-supplied provider — mock the call (we cannot reach external APIs reliably in sandbox) // We'll still try; fallback to local if failure. try { text = await callExternalApi(apiKey, prompt); } catch (e) { console.warn("External API failed, falling back to built-in:", e); text = await window.claude.complete(prompt); } } else { text = await window.claude.complete(prompt); } // Parse JSON const match = text.match(/\[[\s\S]*\]/); if (!match) throw new Error("AI 未返回有效格式"); const data = JSON.parse(match[0]); setResults(data); } catch (e) { console.error(e); setErr(String(e.message || e)); // Fallback: local random pick from templates const shuffled = [...TEMPLATES].sort(() => Math.random() - 0.5).slice(0, count); setResults(shuffled); } finally { setLoading(false); } }, [mood, styleHint, count, apiKey]); return (
AI 笺师 BETA
说出你的心境,由 AI 推荐文字、配色与字体组合
setMood(e.target.value)} placeholder="如:希望今年事业蒸蒸日上" />
{AI_PRESETS.map((p, i) => ( ))}
setStyleHint(e.target.value)} /> setCount(parseInt(e.target.value))} className="slider" /> {err &&
提示:{err}
} {!apiKey?.provider && (
当前使用内置生成。若需对接你自己的 OpenAI / Anthropic key,请点击右上角"接口"。
)}
AI · 生成结果 {results.length} 条建议
{results.length === 0 && !loading && (
说出心境,落墨成笺
)} {loading && (
研墨运笔中…
)} {!loading && results.length > 0 && (
{results.map((r, i) => (
{ set({ ...DEFAULT_STRIP, ...r }); }}>
{r.name || r.text}
{PAPER_COLORS.find(p => p.id === r.paperId)?.name || ""} · {FONTS.find(f => f.id === r.fontId)?.name || ""}
))}
)}
点击任一卡片,将设计载入编辑器
); } async function callExternalApi(apiKey, prompt) { // Sandbox cannot reach external APIs reliably (CORS / origin). This stub attempts but is expected to be replaced by user's backend. const { provider, key } = apiKey; if (provider === "openai") { const r = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}`, }, body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }], temperature: 0.7, }), }); const data = await r.json(); return data.choices[0].message.content; } if (provider === "anthropic") { const r = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01", }, body: JSON.stringify({ model: "claude-haiku-4-5", max_tokens: 1024, messages: [{ role: "user", content: prompt }], }), }); const data = await r.json(); return data.content[0].text; } throw new Error("未知接口"); } // =========================================================== // Canvas / Collage view // =========================================================== function CanvasView({ items, setItems, currentStrip, addStripFrom, onExport, apiKey }) { const [selected, setSelected] = useState(null); const [bg, setBg] = useState("felt"); const [aiLoading, setAiLoading] = useState(false); const boardRef = useRef(null); const updateItem = (id, patch) => { setItems(items.map(it => it.id === id ? { ...it, ...patch } : it)); }; const removeItem = (id) => { setItems(items.filter(it => it.id !== id)); if (selected === id) setSelected(null); }; // Drag logic const dragRef = useRef(null); const handleMouseDown = (e, item) => { e.stopPropagation(); setSelected(item.id); const board = boardRef.current.getBoundingClientRect(); dragRef.current = { id: item.id, startX: e.clientX, startY: e.clientY, origX: item.x, origY: item.y, boardW: board.width, boardH: board.height, }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }; const handleMouseMove = (e) => { if (!dragRef.current) return; const d = dragRef.current; const dx = (e.clientX - d.startX) / d.boardW * 100; const dy = (e.clientY - d.startY) / d.boardH * 100; setItems(prev => prev.map(it => it.id === d.id ? { ...it, x: d.origX + dx, y: d.origY + dy } : it)); }; const handleMouseUp = () => { dragRef.current = null; document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; // Add curated default arrangement const addPreset = () => { const t = TEMPLATES[Math.floor(Math.random() * TEMPLATES.length)]; const id = Date.now() + Math.random(); setItems([...items, { id, ...DEFAULT_STRIP, ...t, x: 20 + Math.random() * 60, y: 20 + Math.random() * 50, scale: 0.7 + Math.random() * 0.5, rotate: -8 + Math.random() * 16, }]); }; const addCurrent = () => { const id = Date.now() + Math.random(); setItems([...items, { id, ...currentStrip, x: 30 + Math.random() * 40, y: 25 + Math.random() * 40, scale: 0.7, rotate: -5 + Math.random() * 10 }]); }; const clearAll = () => setItems([]); const aiCollage = async () => { setAiLoading(true); const theme = prompt("此次拼贴的主题(例:新春、考试、招财)", "招财进宝"); if (!theme) { setAiLoading(false); return; } try { const prompt2 = `生成一组中国传统书法纸条用于拼贴展示,主题:${theme}。 返回严格 JSON 数组,10 条不同的纸条建议,混合不同形状、颜色与字体: [{"text":"4字以内吉祥语","paperId":"","textureId":"","fontId":"","shape":"","sealText":"1字","name":"4字诗意名称"}] 可选 paperId: cream snow rice kraft dan vermilion ochre saffron celadon jade indigo twilight plum lilac sand moss ink gold 可选 textureId: plain cloud gold wave kikko bamboo blossom fibers 可选 fontId: kai song xing cao caoZhi li zhuan shou maocao 可选 shape: rect-v rect-h square diamond circle coin gourd flower pouch tall 混搭形状与颜色,让拼贴丰富生动。`; let text; try { text = await window.claude.complete(prompt2); } catch (e) { throw new Error("AI 调用失败"); } const match = text.match(/\[[\s\S]*\]/); if (!match) throw new Error("AI 未返回有效格式"); const data = JSON.parse(match[0]); const arr = data.map((d, i) => ({ id: Date.now() + i, ...DEFAULT_STRIP, ...d, x: 15 + (i % 4) * 22 + Math.random() * 6, y: 22 + Math.floor(i / 4) * 30 + Math.random() * 6, scale: 0.55 + Math.random() * 0.3, rotate: -10 + Math.random() * 20, })); setItems(arr); } catch (e) { toast("AI 生成失败,使用本地配方"); const arr = [...Array(10)].map((_, i) => { const t = TEMPLATES[Math.floor(Math.random() * TEMPLATES.length)]; return { id: Date.now() + i, ...DEFAULT_STRIP, ...t, x: 15 + (i % 4) * 22 + Math.random() * 6, y: 22 + Math.floor(i / 4) * 30 + Math.random() * 6, scale: 0.55 + Math.random() * 0.3, rotate: -10 + Math.random() * 20, }; }); setItems(arr); } finally { setAiLoading(false); } }; return (
{MOUNTS.map(m => ( ))}
{selected && (() => { const item = items.find(it => it.id === selected); if (!item) return null; return ( updateItem(item.id, { text: e.target.value })}/> updateItem(item.id, { scale: parseFloat(e.target.value) })} className="slider"/> updateItem(item.id, { rotate: parseInt(e.target.value) })} className="slider"/>
); })()}
拼贴画布 {items.length} 张纸条
setSelected(null)}>
e.stopPropagation()}> {items.map(item => { const w = 90 * (item.scale || 1); return (
handleMouseDown(e, item)} onClick={(e) => { e.stopPropagation(); setSelected(item.id); }} >
); })}
拖动纸条调整位置,点击选中后可在左侧精修
导 出
); } // =========================================================== // Templates view // =========================================================== function TemplatesView({ set, setView, library }) { const all = [...TEMPLATES, ...(library || [])]; return (
纸条谱 {all.length} 款配方 · 点击载入编辑器
{all.map((t, i) => (
{ set({ ...DEFAULT_STRIP, ...t }); setView("edit"); }}>
{t.name || t.text}
{(PAPER_COLORS.find(p => p.id === t.paperId) || {}).name} · {(FONTS.find(f => f.id === t.fontId) || {}).name}
))}
); } // =========================================================== // API Key modal // =========================================================== function ApiKeyModal({ onClose, apiKey, setApiKey }) { const [provider, setProvider] = useState(apiKey?.provider || "claude"); const [key, setKey] = useState(apiKey?.key || ""); return (
e.stopPropagation()}>

AI 接口设置

选择 AI 服务并填写 API key,留空则使用内置
{[ { id: "claude", name: "内置 (Claude)" }, { id: "openai", name: "OpenAI" }, { id: "anthropic", name: "Anthropic" }, ].map(p => ( ))}
{provider !== "claude" && ( setKey(e.target.value)} placeholder="sk-..." style={{ fontFamily: 'var(--sans)', fontSize: 13, letterSpacing: '0.04em' }} /> )}
注:浏览器环境下直接调用第三方 API 可能受 CORS 限制。生产环境建议接入自有后端中转。本网站不会上传或保存你的 key(仅存于本地)。
); } // =========================================================== // Export helpers // =========================================================== async function exportNodeToPng(node, transparent = false) { if (!node || !window.htmlToImage) return; // For transparent mode: hide the mount scene layer (the absolute siblings except the strip) let prevBg, prevHidden = []; if (transparent) { // Hide background siblings within the mount const children = node.children; for (let i = 0; i < children.length; i++) { const c = children[i]; // The MountScene wraps the strip; for transparent we want only the .strip // It's complex — fallback to just rendering with no background. } prevBg = node.style.background; node.style.background = "transparent"; } try { const dataUrl = await window.htmlToImage.toPng(node, { pixelRatio: 2, cacheBust: true, backgroundColor: transparent ? null : undefined, }); const link = document.createElement("a"); link.download = `墨笺-${Date.now()}.png`; link.href = dataUrl; link.click(); } finally { if (transparent) { node.style.background = prevBg || ""; } } } async function copyNodeToClipboard(node) { if (!node || !window.htmlToImage) return; try { const blob = await window.htmlToImage.toBlob(node, { pixelRatio: 2, cacheBust: true }); if (navigator.clipboard && window.ClipboardItem) { await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); toast("已复制到剪贴板"); } else { toast("浏览器不支持剪贴板,已改为下载"); const link = document.createElement("a"); link.download = `墨笺-${Date.now()}.png`; link.href = URL.createObjectURL(blob); link.click(); } } catch (e) { console.error(e); toast("复制失败:" + e.message); } } function toast(msg) { const t = document.createElement("div"); t.textContent = msg; t.style.cssText = `position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1a1612;color:#f5efe4;padding:10px 20px;border-radius:4px;font-family:var(--serif);letter-spacing:.2em;font-size:13px;z-index:9999;box-shadow:0 6px 20px rgba(0,0,0,.3)`; document.body.appendChild(t); setTimeout(() => t.remove(), 2400); } // =========================================================== // Tweaks defaults // =========================================================== const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#c4453a", "uiDensity": "正常", "stripScale": 1, "stageVignette": true, "introMode": "编辑" }/*EDITMODE-END*/; // =========================================================== // Root App // =========================================================== function App() { const [view, setView] = useState("edit"); const [strip, setStrip] = useState(DEFAULT_STRIP); const [showApiModal, setShowApiModal] = useState(false); const [apiKey, setApiKey] = useState(() => { try { return JSON.parse(localStorage.getItem("mojian.apiKey") || "null"); } catch { return null; } }); const [canvasItems, setCanvasItems] = useState([]); const [library, setLibrary] = useState(() => { try { return JSON.parse(localStorage.getItem("mojian.library") || "[]"); } catch { return []; } }); const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); useEffect(() => { localStorage.setItem("mojian.apiKey", JSON.stringify(apiKey)); }, [apiKey]); useEffect(() => { localStorage.setItem("mojian.library", JSON.stringify(library)); }, [library]); useEffect(() => { document.documentElement.style.setProperty("--dan", t.accent); }, [t.accent]); const set = (patch) => setStrip(prev => ({ ...prev, ...patch })); const onExport = useCallback((node, mode) => { const target = node || document.querySelector("[data-export-target]"); exportNodeToPng(target, mode === "transparent"); }, []); const onCopy = useCallback((node) => { const target = node || document.querySelector("[data-export-target]"); copyNodeToClipboard(target); }, []); const onAddToCanvas = (s) => { setCanvasItems(prev => [...prev, { id: Date.now(), ...s, x: 35 + Math.random() * 30, y: 30 + Math.random() * 30, scale: 0.7, rotate: -5 + Math.random() * 10 }]); setView("canvas"); }; const onSaveTemplate = (s) => { const name = prompt("起一个名字(4字以内):", s.text); if (!name) return; setLibrary(prev => [...prev, { ...s, name }]); toast("已加入纸条谱"); }; return (
setShowApiModal(true)} onExport={() => onExport()} apiKey={apiKey} /> {view === "edit" && ( )} {view === "ai" && ( setShowApiModal(true)} library={library} addToLibrary={(t) => setLibrary([...library, t])} /> )} {view === "canvas" && ( setCanvasItems([...canvasItems, t])} onExport={onExport} /> )} {view === "tpl" && ( )} {showApiModal && ( setShowApiModal(false)} apiKey={apiKey} setApiKey={setApiKey} /> )} setTweak({ accent: v })} /> setTweak({ uiDensity: v })} /> setTweak({ stageVignette: v })} /> setTweak({ stripScale: v })} /> { setTweak({ introMode: v }); const m = { "编辑": "edit", "AI": "ai", "谱": "tpl" }; setView(m[v] || "edit"); }} />
); } const root = ReactDOM.createRoot(document.getElementById("root")); root.render();