/* ============================================================
* 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 · 生成结果
{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 (
拼贴画布
{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();