// Panels.jsx — 新建笔记面板 + 内页书写视图
const { useState: useStateP, useRef: useRefP, useEffect: useEffectP, useMemo: useMemoExp } = React;
// ============ 新建笔记 ============
function NewNote({ onClose, defaultTexture }) {
const [tab, setTab] = useStateP('photo'); // photo | text
const [drag, setDrag] = useStateP(false);
const [hasFile, setHasFile] = useStateP(false);
const [text, setText] = useStateP('');
const [hist, setHist] = useStateP(false);
const today = '2026.05.31';
const canSubmit = tab === 'photo' ? hasFile : text.trim().length > 0;
return (
新建笔记
{tab === 'photo' ? (
hasFile ? (
已选择手写照片 · 预览
) : (
{ e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={e => { e.preventDefault(); setDrag(false); setHasFile(true); }}
onClick={() => setHasFile(true)}>
把手写的一页拖到这里
或点击选择 · 支持 JPG / PNG / HEIC
)
) : (
);
}
// ============ 内页书写视图 ============
const PCATS = [
{ id: 'travel', name: '旅行', icon: 'mapPin' },
{ id: 'film', name: '电影', icon: 'eye' },
{ id: 'music', name: '音乐', icon: 'music' },
{ id: 'shop', name: '购物', icon: 'wallet' },
{ id: 'museum', name: '博物馆', icon: 'book' },
{ id: 'grow', name: '成长', icon: 'heart' },
];
const TEX = [
{ id: 'grid', icon: 'grid', label: '方格' },
{ id: 'lines', icon: 'lines', label: '横线' },
{ id: 'dots', icon: 'dots', label: '点状' },
{ id: 'blank', icon: 'blank', label: '留白' },
];
// 标题旁的生成工具:文生图 / 文生视频 / 文生音乐 / 连接 / PPT / HTML
const PAPER_TOOLS = [
{ id: 'image', name: '文生图', kind: 'image', gicon: 'image', done: '一幅插画' },
{ id: 'video', name: '文生视频', kind: 'video', gicon: 'video', done: '短片草稿' },
{ id: 'music', name: '文生音乐', kind: 'audio', gicon: 'music', done: '配乐 · 0:30' },
{ id: 'tts', name: '配音 · TTS', kind: 'tts', icon: 'mic', done: '语音 · 已生成' },
{ id: 'youtube', name: 'YouTube 片段', kind: 'youtube', icon: 'film', done: '已插入片段' },
{ id: 'link', name: '连接', kind: 'link', icon: 'link', done: '已建立连接' },
{ id: 'deck', name: 'PPT', kind: 'deck', gicon: 'deck', done: '提案 · 5 页' },
{ id: 'html', name: 'HTML', kind: 'html', gicon: 'html', done: '网页一页' },
];
// —— YouTube 片段:粘 URL → 选时间段 → 内嵌引用 —— //
function ytParse(url) {
if (!url) return null;
const m = url.match(/(?:youtu\.be\/|[?&]v=|embed\/|shorts\/)([\w-]{11})/);
const id = m ? m[1] : (/^[\w-]{11}$/.test(url.trim()) ? url.trim() : null);
if (!id) return null;
let start = 0; const tm = url.match(/[?&](?:t|start)=(\d+)/); if (tm) start = +tm[1];
return { id, start };
}
function ytToSec(str) {
if (!str) return 0;
const p = String(str).trim().split(':').map(x => parseInt(x || '0', 10));
if (p.length === 3) return p[0] * 3600 + p[1] * 60 + p[2];
if (p.length === 2) return p[0] * 60 + p[1];
return parseInt(str, 10) || 0;
}
function ytFmt(s) { s = Math.max(0, Math.floor(s || 0)); const m = Math.floor(s / 60), ss = s % 60; return m + ':' + String(ss).padStart(2, '0'); }
function YoutubeBlock({ title, onDel, rid, onMedia }) {
const [url, setUrl] = useStateP('');
const [vid, setVid] = useStateP(null);
const [startStr, setStartStr] = useStateP('0:00');
const [endStr, setEndStr] = useStateP('');
const [embed, setEmbed] = useStateP(null);
const [err, setErr] = useStateP('');
function parse() {
const r = ytParse(url);
if (!r) { setErr('没认出 YouTube 链接,确认是否完整'); return; }
setErr(''); setVid(r.id);
if (r.start) setStartStr(ytFmt(r.start));
build(r.id, r.start ? ytFmt(r.start) : startStr, endStr);
}
function build(id, s, e) {
const ss = ytToSec(s), ee = ytToSec(e);
let src = 'https://www.youtube.com/embed/' + id + '?rel=0&start=' + ss;
if (ee > ss) src += '&end=' + ee;
setEmbed(src);
if (onMedia && rid != null) onMedia(rid, {
kind: 'youtube', embed: src, name: 'YouTube 片段',
span: ytFmt(ss) + (ee > ss ? '–' + ytFmt(ee) : ' 起'),
});
}
function update() { if (vid) build(vid, startStr, endStr); }
return (
YouTube 片段
{vid ? '已引用片段' : '粘贴链接'}
{vid ? '源自 YouTube · ' + ytFmt(ytToSec(startStr)) + (endStr ? '–' + ytFmt(ytToSec(endStr)) : ' 起') + ' · 引用即带出处' : '引用一段视频,作为这一页的素材'}
);
}
// —— 墨吞 · 第四面:找灵感(帮你写下一句)—— //
// 跨域 / 跨时 / 跨地 / 接续 / 借声;每条灵感带来源,可"采纳·写进页"或"让墨吞接着写"。
function Inspire({ note, onAsk, onAdopt, getDraft }) {
const [mode, setMode] = useStateP('cross');
const firstTag = (note && note.notes && note.notes[0]) || (note && note.cat) || null;
const [tag, setTag] = useStateP(firstTag);
const [voiceKind, setVoiceKind] = useStateP('author');
const [openRef, setOpenRef] = useStateP(null);
const focus = mode === 'cross' ? tag : mode === 'voice' ? voiceKind : null;
const localCards = useMemoExp(() => (typeof inspireCards === 'function' ? inspireCards(note, mode, focus) : []), [note, mode, focus]);
// 语义浮现:cross / time / place 三档先问后端,拿到 hits 就用语义结果;否则回落本地卡
const SEMANTIC = mode === 'cross' || mode === 'time' || mode === 'place';
const [apiCards, setApiCards] = useStateP(null); // null = 用本地;数组 = 用语义
const [emerging, setEmerging] = useStateP(false);
const [provider, setProvider] = useStateP(null);
useEffectP(() => {
setApiCards(null);
if (!SEMANTIC || !note || typeof emergeFetch !== 'function') return;
let alive = true;
const base = (getDraft && getDraft()) || note.body || '';
const text = mode === 'cross' && tag ? base + '\n关注:' + tag : base;
setEmerging(true);
emergeFetch(text, note.id, 3).then(hits => {
if (!alive) return;
setApiCards(hits ? emergeToCards(hits, mode) : null);
}).finally(() => { if (alive) setEmerging(false); });
return () => { alive = false; };
}, [note, mode, focus]);
const cards = (SEMANTIC && apiCards) ? apiCards : localCards;
const tagOpts = note ? [].concat(note.cat ? [note.cat] : [], note.notes || []) : [];
const mood = note && note.mood;
const whyIcon = { tag: 'spark', note: 'book', mood: 'heart', person: 'users', voice: 'quote' };
const refNote = id => ((typeof window !== 'undefined' && window.NOTES) || []).find(n => n.id === id);
const curMode = INSPIRE_MODES.find(m => m.id === mode) || INSPIRE_MODES[0];
// 借声 · 名家笔法:两个按钮接 /api/notes/{id}/restyle;失败回落到原有行为
const [voiceBusy, setVoiceBusy] = useStateP(null); // card.id + ':' + rmode
const [voiceErr, setVoiceErr] = useStateP(null); // { id, msg }
async function doRestyle(card, rmode) {
if (!note || !note.id || typeof restyleNote !== 'function') {
rmode === 'continue' ? (onAsk && onAsk(card.draftSeed)) : (onAdopt && onAdopt(card.q));
return;
}
setVoiceErr(null);
setVoiceBusy(card.id + ':' + rmode);
try {
const sel = getDraft && getDraft();
const r = await restyleNote(note.id, card.restyle, rmode, sel || undefined);
if (r && r.styled_text) onAdopt && onAdopt(r.styled_text);
} catch (e) {
// 借声后端不可达 → 回落:重写=写进原句,接着写=交本地墨吞续写
setVoiceErr({ id: card.id, msg: (e && e.message) || '借声暂不可用,已用本地方式接续' });
rmode === 'continue' ? (onAsk && onAsk(card.draftSeed)) : (onAdopt && onAdopt(card.q));
} finally {
setVoiceBusy(null);
}
}
return (
帮你写下一句 —— 把灵感从你自己写过的东西里捞出来
{mood &&
心情 {mood} · 只你能改}
{INSPIRE_MODES.map(m => (
))}
{curMode.hint}
{mode === 'cross' && tagOpts.length > 0 && (
你的标签 · 选一个面
{tagOpts.map(t => (
))}
)}
{mode === 'voice' && (
借谁的声音
{VOICE_SUBS.map(v => (
))}
)}
{SEMANTIC && emerging && (
正从你写过的页里浮现……
)}
{cards.map(c => {
const rn = (c.refId && refNote(c.refId)) || c.emergeRef;
const fromApi = !!c.emergeRef;
const isVoice = !!c.restyle;
const busyR = voiceBusy === c.id + ':rewrite';
const busyC = voiceBusy === c.id + ':continue';
return (
{c.q}
{openRef === c.id && rn && (
“{rn.preview}”—《{rn.title}》{rn.date ? ' · ' + rn.date : ''}{rn.city ? ' · ' + rn.city : ''}
)}
{isVoice ? (
) : (
)}
{voiceErr && voiceErr.id === c.id &&
{voiceErr.msg}
}
);
})}
{!cards.length && !emerging &&
这一页还太空——先写两句,墨吞就能从你的本子里给你接灵感。
}
);
}
// —— 墨吞 · 书写时的对话助手(提问 + 找资料 · 资料带来源 index)—— //
function MotunChat({ note, onClose, getDraft, onCite, onAdopt }) {
const open = motunOpening(note);
const [tab, setTab] = useStateP('chat'); // chat 问墨吞 / people 找人 / sources 找资料
const [msgs, setMsgs] = useStateP([{ role: 'ai', text: open.text }]);
const [refs, setRefs] = useStateP([]); // 累积引用资料(论文式 index)
const [input, setInput] = useStateP('');
const [typing, setTyping] = useStateP(false);
const scrollRef = useRefP(null);
const live = hasActiveKey();
const engineName = activeProviderName();
useEffectP(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [msgs, typing]);
function send(text) {
const q = (text || '').trim();
if (!q || typing) return;
setInput('');
const myMsg = { role: 'me', text: q };
setMsgs(m => [...m, myMsg]);
setTyping(true);
// —— 已接入 DeepSeek:真实调用,带上当前草稿作为上下文 ——
if (live) {
const draft = (getDraft && getDraft()) || '';
const sys = { role: 'system', content: '你是「墨吞」,popor 笔记本里的中文写作助手。基于用户当前草稿,帮他续写、润色、提炼要点或打开思路。回答简洁、只用简体中文,不要傅衍。' };
const ctx = draft.trim() ? [{ role: 'user', content: '【我正在写的草稿】\n' + draft.slice(0, 4000) }] : [];
const history = msgs.filter(m => m.role === 'me' || m.role === 'ai')
.map(m => ({ role: m.role === 'me' ? 'user' : 'assistant', content: m.text }));
deepseekChat([sys, ...ctx, ...history, { role: 'user', content: q }])
.then(reply => setMsgs(m => [...m, { role: 'ai', text: reply }]))
.catch(e => setMsgs(m => [...m, { role: 'ai', err: true,
text: '⚠ ' + (e.message || '调用失败') + (e.code === 'NO_KEY' ? '· 请在顶栏「墨吞」里填入密钥' : '') }]))
.finally(() => setTyping(false));
return;
}
// —— 未接入:本地演示回复(带出处资料) ——
setTimeout(() => {
setRefs(prevRefs => {
const { text: reply, cite } = motunReply(note, q, prevRefs.length);
const next = [...prevRefs];
const idxOf = src => {
let f = next.findIndex(r => r.title === src.title);
if (f === -1) { next.push(src); f = next.length - 1; }
return f + 1;
};
const citeIdx = cite.map(idxOf);
setMsgs(m => [...m, { role: 'ai', text: reply, cite: cite.map((s, i) => ({ ...s, n: citeIdx[i] })) }]);
return next;
});
setTyping(false);
}, 760);
}
return (
);
}
// —— 把当前页拼成导出元数据 —— //
function buildExportMeta(note, title, body, media) {
const stamp = NOW.date + ' ' + NOW.time + ' UTC+8';
const hash = contentHash((note ? note.id : 'new') + '|' + title + '|' + body + '|' + stamp);
const shareId = hash.replace(/\s/g, '').slice(0, 8);
return {
title: title,
en: note ? note.en : '',
date: note && note.date ? note.date : NOW.date,
city: note ? note.city : NOW.city,
scene: note ? note.scene : NOW.scene,
gps: note ? note.gps : '',
cat: note ? note.cat : '',
notes: note ? (note.notes || []) : [],
locs: note ? (note.locs || []) : [],
body: body, media: media || [], stamp: stamp, hash: hash, shareId: shareId,
};
}
// —— 导出面板:网页 / 幻灯片 / Markdown,左选格式 · 右实时预览 —— //
const EXPORT_FORMATS = [
{ id: 'html', name: '网页', ext: 'html', mime: 'text/html;charset=utf-8', icon: 'globe',
tagline: '推荐', desc: '一个独立的网页:文字、媒体与凭证都在里面。双击即开,发给朋友,或当作你的小站。' },
{ id: 'slides', name: '幻灯片', ext: 'html', mime: 'text/html;charset=utf-8', icon: 'deck',
tagline: '内测', desc: '把这一页排成几张幻灯片,可放映,也可 ⌘/Ctrl+P 存成 PDF / PPT。' },
{ id: 'md', name: 'Markdown', ext: 'md', mime: 'text/markdown;charset=utf-8', icon: 'hash',
tagline: '', desc: '纯文本加元数据,方便在别处继续编辑或归档。' },
];
function ExportSheet({ note, title, getContent, getMedia, onClose }) {
const doc = useMemoExp(() => ({
body: (getContent && getContent()) || (note ? note.body : ''),
media: getMedia ? getMedia() : [],
}), []);
const meta = useMemoExp(() => buildExportMeta(note, title, doc.body, doc.media), [note, title, doc]);
const [fmt, setFmt] = useStateP('html');
const [done, setDone] = useStateP(null);
const fmtDef = EXPORT_FORMATS.find(f => f.id === fmt);
const source = useMemoExp(() => {
if (fmt === 'slides') return buildSlidesHTML(meta);
if (fmt === 'md') return buildMarkdownExport(meta);
return buildShowcaseHTML(meta);
}, [fmt, meta]);
function download() {
if (fmt === 'md') {
downloadFile(safeName(title) + '.md', source, fmtDef.mime);
} else {
const suffix = fmt === 'slides' ? ' · 幻灯片' : '';
downloadFile(safeName(title) + suffix + '.html', source, fmtDef.mime);
}
archiveLocally({ title: title, stamp: meta.stamp, hash: meta.hash, chars: doc.body.trim().length, fmt: fmt, at: Date.now() });
setDone(true);
setTimeout(() => setDone(false), 2200);
}
return (
{ if (e.target === e.currentTarget) onClose(); }}>
导出 · 把这一页带走
《{title}》 · {meta.stamp}
选择格式
{EXPORT_FORMATS.map(f => (
))}
每份导出都带一枚数字凭证:内容指纹 + 时间戳,可锚定到公链(OpenTimestamps)作为存在证明。
{fmt === 'md' ? safeName(title) + '.md' : 'popor.ink/p/' + meta.shareId}
{fmt === 'md'
?
{source}
:
}
导出的网页可独立打开、发给朋友,也能成为你的个人小站的一页。
);
}
// —— 完成 → 分享 / 导出 —— //
function SaveFlow({ note, title, getContent, getMedia, onClose }) {
const [step, setStep] = useStateP('choose'); // choose | cert | export
const stamp = NOW.date + ' ' + NOW.time + ' UTC+8';
const body = (getContent && getContent()) || (note ? note.body : '');
const hash = contentHash((note ? note.id : 'new') + '|' + title + '|' + body + '|' + stamp);
if (step === 'export') {
return ;
}
return (
{ if (e.target === e.currentTarget) onClose(); }}>
{step === 'choose' ? (
这一页,写完了
《{title}》 · {stamp}
无论哪种,都会为这一页生成一枚数字凭证。
) : (
已生成可分享的凭证
数字凭证 · Proof of Authorship
页面{title}
确权时间{stamp}
内容指纹{hash}
分享链接popor.ink/p/{hash.replace(/\s/g, '').slice(0, 8)}
指纹经 SHA-256 计算(此处为演示哈希),可锚定到公链时间戳,作为这页文字、图像与创意属于你的数字证据。
钤
印
)}
);
}
// —— 内嵌生成块(墨吞工具产物,落在正文光标处)—— //
// 媒体块支持「AI 生成 ↔ 用我的素材」;TTS 走独立块(合成 / 克隆 / 上传)。
const OWN_ACCEPT = { image: 'image/*', video: 'video/*', audio: 'audio/*' };
function OwnMedia({ kind, url, type }) {
if (!url) return null;
if (kind === 'image') return
;
if (kind === 'video') return ;
if (kind === 'audio') return ;
return null;
}
function InsertBlock({ tool, status, title, onDel, rid, onMedia }) {
if (tool.kind === 'tts') return ;
if (tool.kind === 'youtube') return ;
const isMedia = tool.kind === 'image' || tool.kind === 'video' || tool.kind === 'audio';
const [mode, setMode] = useStateP('ai'); // ai | own
const [ownUrl, setOwnUrl] = useStateP(null); // data: URL(自包含,导出后仍可用)
const [ownType, setOwnType] = useStateP(null);
const fileRef = useRefP(null);
// 把当前块的媒体上报给书写台(用于内联进产品)
useEffectP(() => {
if (!onMedia || rid == null) return;
if (isMedia && mode === 'own') {
onMedia(rid, ownUrl ? { kind: tool.kind, src: ownUrl, mime: ownType, name: tool.name, own: true } : null);
} else {
onMedia(rid, status === 'done' ? { kind: tool.kind, name: tool.name, done: tool.done, placeholder: true } : null);
}
}, [mode, ownUrl, ownType, status]);
async function ingest(f) {
if (!f) return;
try { setOwnUrl(await readAsDataURL(f)); } catch (_) { setOwnUrl(URL.createObjectURL(f)); }
setOwnType(f.type);
}
function onPick(e) { const f = e.target.files && e.target.files[0]; e.target.value = ''; ingest(f); }
function onDrop(e) { e.preventDefault(); ingest(e.dataTransfer.files && e.dataTransfer.files[0]); }
return (
{tool.gicon ? React.createElement(GIcon[tool.gicon], { size: 14 }) :
}
{tool.name}
{isMedia && (
)}
{mode === 'ai' && (status === 'gen'
?
生成中
:
{tool.done})}
{mode === 'ai'
? (status === 'gen' ?
:
)
: (ownUrl
?
:
)}
{mode === 'ai' ? '源自《' + title + '》 · 墨吞生成 · 已插入光标处' : '你的素材 · 已插入这一页'}
);
}
// —— TTS 块:文本 → 语音(AI 音色 / 克隆我的声音 / 作者朗读 · 录音 / 上传音频)—— //
function TtsBlock({ tool, title, onDel, rid, onMedia }) {
const [text, setText] = useStateP('');
const [source, setSource] = useStateP('record'); // record | ai | clone | own
const [voice, setVoice] = useStateP(() => getTtsVoice());
const [cloned, setClonedV] = useStateP(() => getClonedVoice());
const [audioUrl, setAudioUrl] = useStateP(null);
const [busy, setBusy] = useStateP(false);
const [err, setErr] = useStateP('');
const [setup, setSetup] = useStateP(!hasTts());
const [keyInput, setKeyInput] = useStateP(() => getTtsKey('elevenlabs'));
const [prov, setProv] = useStateP(() => getTtsProvider());
// 录音(克隆素材 / 作者朗读)
const [recording, setRecording] = useStateP(false);
const [recUrl, setRecUrl] = useStateP(null);
const recRef = useRefP(null);
const chunksRef = useRefP([]);
const blobRef = useRefP(null);
const fileRef = useRefP(null);
const SRC_LABEL = { record: '作者朗读', ai: 'AI 音色', clone: '克隆我声', own: '上传音频' };
// 录音直接作为「作者朗读」结果
useEffectP(() => { if (source === 'record' && recUrl) setAudioUrl(recUrl); }, [recUrl, source]);
// 把合成 / 朗读的语音上报给书写台(内联进产品 · 自包含 data URL)
useEffectP(() => {
if (!onMedia || rid == null) return;
if (!audioUrl) { onMedia(rid, null); return; }
let alive = true;
const label = SRC_LABEL[source] || '配音';
blobUrlToDataUrl(audioUrl)
.then(src => { if (alive) onMedia(rid, { kind: 'voice', src, name: '配音 · ' + label, label, author: source === 'record' || source === 'clone' }); })
.catch(() => {});
return () => { alive = false; };
}, [audioUrl, source]);
function saveSetup() {
setTtsProvider(prov); setTtsKey(prov, keyInput.trim());
if (keyInput.trim()) setSetup(false);
}
async function synth() {
if (!text.trim() || busy) return;
setBusy(true); setErr('');
try {
const vId = source === 'clone' && cloned ? cloned.id : voice;
const url = await ttsSynthesize(text.trim(), { voiceId: vId });
setAudioUrl(url);
} catch (e) { setErr(e.message || '合成失败'); if (e.code === 'NO_KEY') setSetup(true); }
finally { setBusy(false); }
}
async function startRec() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunksRef.current = [];
const mr = new MediaRecorder(stream);
mr.ondataavailable = e => { if (e.data.size) chunksRef.current.push(e.data); };
mr.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
blobRef.current = blob; setRecUrl(URL.createObjectURL(blob));
stream.getTracks().forEach(t => t.stop());
};
recRef.current = mr; mr.start(); setRecording(true);
} catch (e) { setErr('无法录音:' + e.message); }
}
function stopRec() { if (recRef.current) recRef.current.stop(); setRecording(false); }
async function doClone() {
if (!blobRef.current || busy) return;
setBusy(true); setErr('');
try {
const v = await cloneVoice('我的声音 · ' + title, blobRef.current);
setClonedV(v); setSource('clone');
} catch (e) { setErr(e.message || '克隆失败'); if (e.code === 'NO_KEY') setSetup(true); }
finally { setBusy(false); }
}
function onOwnAudio(e) {
const f = e.target.files && e.target.files[0]; e.target.value = '';
if (f) setAudioUrl(URL.createObjectURL(f));
}
return (
配音 · 文本转语音
setSetup(s => !s)}>
{source === 'record' ? '作者朗读 · 无需引擎'
: hasTts() ? ((TTS_PROVIDERS.find(p => p.id === prov) || {}).name || prov) : '未接入语音引擎'}
{setup && (source === 'ai' || source === 'clone') && (
{TTS_PROVIDERS.map(p => (
))}
p.id === prov) || {}).keyHint}
onChange={e => setKeyInput(e.target.value)} spellCheck={false} />
国内可选 千问 Qwen-TTS 或 CosyVoice(通义/百炼),国外推荐 ElevenLabs;浏览器直连被拦时,在墨吞·进阶填代理地址。
)}
源自《{title}》 · {source === 'record' ? '作者朗读 · 你的声音' : source === 'own' ? '你的音频' : '文本 → 语音'} · 内联进这一页
);
}
// —— 调用技能:墨吞引擎跑出结果,落到正文 —— //
function SkillBlock({ item, engineName, onAdopt, onRerun, onDel }) {
const s = item.skill;
return (
{s.name}
{item.status === 'gen' && {engineName} 运行中}
{item.status === 'done' && 由 {engineName} 生成}
{item.status === 'error' && 出错}
{item.status === 'needkey' && 未接入引擎}
{item.status === 'gen' &&
}
{item.status === 'done' &&
{item.text}
}
{item.status === 'error' &&
{item.text}
}
{item.status === 'needkey' && (
这是文字技能,需要墨吞接入一个引擎才能运行。到顶栏「墨吞」接入 DeepSeek / Gemini / Grok / ChatGPT / 千问 任意一家,再回来调用。
)}
{item.status === 'done' && (
{s.custom ? '我自制' : (s.by === '官方' ? '官方技能' : (s.byName || s.by))} · 跑在你接入的引擎
)}
);
}
function PaperView({ note, onClose, onPersist, defaultTexture, onOpenStore }) {
const [tex, setTex] = useStateP(defaultTexture || 'grid');
const [full, setFull] = useStateP(true);
const [chat, setChat] = useStateP(true);
const [inputMode, setInputMode] = useStateP('keyboard'); // keyboard | voice | pen
const [inserts, setInserts] = useStateP([]);
const [busy, setBusy] = useStateP(null);
const [skillOpen, setSkillOpen] = useStateP(false);
const [skills, setSkills] = useStateP(() => (typeof getInstalledSkills === 'function' ? getInstalledSkills() : []));
const [saving, setSaving] = useStateP(false);
const [title, setTitle] = useStateP(note ? note.title : '新的一页');
const [emergeFloat, setEmergeFloat] = useStateP(null); // 停顿浮现:从旧页浮上来的一张卡
const emergeTimer = useRefP(null);
const lastEmergeAt = useRefP(0);
const canvasRef = useRefP(null);
const savedRange = useRefP(null);
const mediaRef = useRefP({}); // { [rid]: 媒体载荷 } —— 各媒体块上报的真实内容
function registerMedia(rid, payload) {
if (payload == null) delete mediaRef.current[rid];
else mediaRef.current[rid] = payload;
}
const [cats, setCats] = useStateP(() => {
const s = new Set();
if (note) { const found = PCATS.find(c => c.name === note.cat); if (found) s.add(found.id); }
return s;
});
function toggle(id) { const s = new Set(cats); s.has(id) ? s.delete(id) : s.add(id); setCats(s); }
// 去掉首个标题,正文铺到纸上
const bodyText = note ? note.body.replace(/^#\s.*\n?/, '').trim() : '';
// 首次把正文写进纸面(之后画布交给浏览器,React 不再接管,便于在其中插入媒体块)
useEffectP(() => {
const el = canvasRef.current;
if (!el) return;
el.innerHTML = '';
if (note && note._image) {
const fig = document.createElement('p');
fig.setAttribute('contenteditable', 'false');
const img = document.createElement('img');
img.src = note._image; img.className = 'paper-img'; img.alt = note.title || '';
fig.appendChild(img);
el.appendChild(fig);
}
const lines = bodyText ? bodyText.split('\n') : [''];
lines.forEach(ln => {
const p = document.createElement('p');
if (ln) p.textContent = ln; else p.appendChild(document.createElement('br'));
el.appendChild(p);
});
}, []);
// 记下光标位置(仅当落在纸面内)
function saveSel() {
const sel = window.getSelection();
if (sel && sel.rangeCount && canvasRef.current && canvasRef.current.contains(sel.anchorNode)) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
}
// —— 停顿浮现:连续写作 >3 句、停笔 >8 秒、距上次 >5 分钟 → 让一张旧页浮上来 ——
function countSentences(t) { return ((t || '').match(/[。!?!?…]|\n/g) || []).length; }
function scheduleEmerge() {
if (emergeTimer.current) clearTimeout(emergeTimer.current);
emergeTimer.current = setTimeout(async () => {
if (typeof emergeFetch !== 'function') return;
const body = collectBody();
if (countSentences(body) < 3) return; // 写够 3 句才浮现
if (Date.now() - lastEmergeAt.current < 5 * 60 * 1000) return; // 距上次 >5 分钟
const hits = await emergeFetch(body, note && note.id, 1);
if (!hits || !hits.length) return; // 后端不可达 / 无命中 → 静默不打扰
lastEmergeAt.current = Date.now();
setEmergeFloat(emergeToCards(hits, 'time')[0]);
}, 8000);
}
function onCanvasInput() { saveSel(); scheduleEmerge(); }
// 浮现卡 30 秒无响应自动淡出;离场清掉计时器
useEffectP(() => {
if (!emergeFloat) return;
const t = setTimeout(() => setEmergeFloat(null), 30000);
return () => clearTimeout(t);
}, [emergeFloat]);
useEffectP(() => () => { if (emergeTimer.current) clearTimeout(emergeTimer.current); }, []);
// 标题旁工具:在光标所在段落之后插入一个媒体块(图文混编)
function runTool(tool) {
if (busy) return;
setBusy(tool.id);
const rid = Date.now();
const canvas = canvasRef.current;
const anchor = document.createElement('div');
anchor.className = 'pins-anchor';
anchor.setAttribute('contenteditable', 'false');
const r = savedRange.current;
let placed = false;
if (canvas && r && canvas.contains(r.startContainer)) {
// 找到光标所在的直接子块
let node = r.startContainer;
while (node && node.parentNode !== canvas) node = node.parentNode;
if (node && node.parentNode === canvas) {
canvas.insertBefore(anchor, node.nextSibling);
const after = document.createElement('p');
after.appendChild(document.createElement('br'));
canvas.insertBefore(after, anchor.nextSibling);
const nr = document.createRange();
nr.setStart(after, 0); nr.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(nr);
savedRange.current = nr.cloneRange();
placed = true;
}
}
if (!placed && canvas) canvas.appendChild(anchor);
setInserts(list => [...list, { rid, tool, status: 'gen', node: anchor }]);
setTimeout(() => {
setInserts(list => list.map(x => x.rid === rid ? { ...x, status: 'done' } : x));
setBusy(null);
}, 1100);
}
function removeInsert(rid) {
delete mediaRef.current[rid];
setInserts(list => {
const t = list.find(x => x.rid === rid);
if (t && t.node && t.node.parentNode) t.node.parentNode.removeChild(t.node);
return list.filter(x => x.rid !== rid);
});
}
// —— 在光标处放一个 contenteditable=false 的锚点块,返回它 ——
function placeAnchor() {
const canvas = canvasRef.current;
const anchor = document.createElement('div');
anchor.className = 'pins-anchor';
anchor.setAttribute('contenteditable', 'false');
const r = savedRange.current;
let placed = false;
if (canvas && r && canvas.contains(r.startContainer)) {
let node = r.startContainer;
while (node && node.parentNode !== canvas) node = node.parentNode;
if (node && node.parentNode === canvas) {
canvas.insertBefore(anchor, node.nextSibling);
const after = document.createElement('p');
after.appendChild(document.createElement('br'));
canvas.insertBefore(after, anchor.nextSibling);
const nr = document.createRange();
nr.setStart(after, 0); nr.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(nr);
savedRange.current = nr.cloneRange();
placed = true;
}
}
if (!placed && canvas) canvas.appendChild(anchor);
return anchor;
}
// —— 调用技能:文字类走墨吞引擎,影像类落到生成工具 ——
function runSkill(skill) {
setSkillOpen(false);
const toolKind = (typeof SKILL_TOOLMAP === 'object') ? SKILL_TOOLMAP[skill.id] : null;
if (toolKind) {
const tl = PAPER_TOOLS.find(t => t.kind === toolKind);
if (tl) runTool(tl);
return;
}
const prompt = (typeof skillPrompt === 'function') ? skillPrompt(skill) : skill.prompt;
if (!prompt) return;
const rid = Date.now();
const anchor = placeAnchor();
const draft = collectBody();
const hasKey = (typeof hasActiveKey === 'function') && hasActiveKey();
if (!hasKey) {
setInserts(list => [...list, { rid, skill, kind: 'skill', status: 'needkey', node: anchor, text: '' }]);
return;
}
setInserts(list => [...list, { rid, skill, kind: 'skill', status: 'gen', node: anchor, text: '' }]);
aiChat([{ role: 'system', content: prompt }, { role: 'user', content: draft || '(这一页还没写内容,请据技能直接给一个开头)' }], {})
.then(text => setInserts(list => list.map(x => x.rid === rid ? Object.assign({}, x, { status: 'done', text: (text || '').trim() }) : x)))
.catch(e => setInserts(list => list.map(x => x.rid === rid ? Object.assign({}, x, { status: 'error', text: e.message || '调用失败' }) : x)));
}
// 采用技能结果 → 拆成正文段落,替换掉技能块
function adoptSkill(rid) {
setInserts(list => {
const t = list.find(x => x.rid === rid);
if (t && t.node && t.node.parentNode && t.text) {
const frag = document.createDocumentFragment();
t.text.split(/\n+/).forEach(line => {
const p = document.createElement('p');
if (line.trim()) p.textContent = line.trim(); else p.appendChild(document.createElement('br'));
frag.appendChild(p);
});
t.node.parentNode.insertBefore(frag, t.node);
t.node.parentNode.removeChild(t.node);
}
return list.filter(x => x.rid !== rid);
});
}
function rerunSkill(rid) {
const t = inserts.find(x => x.rid === rid);
if (!t) return;
removeInsert(rid);
setTimeout(() => runSkill(t.skill), 30);
}
// 把纸面内容收集为 { 纯文本, 按序媒体 }(供墨吞上下文 / 保存导出 / 内联进产品)
function collectDoc() {
const el = canvasRef.current;
if (!el) return { body: note ? (note.body || '') : '', media: [] };
const parts = [], media = [];
el.childNodes.forEach(n => {
if (n.nodeType === 1 && n.classList && n.classList.contains('pins-anchor')) {
const ins = inserts.find(x => x.node === n);
if (!ins) return;
if (ins.kind === 'skill') { // 文字技能:取已采用前的草稿文本
if (ins.status === 'done' && ins.text) parts.push(ins.text);
return;
}
// 媒体块:正文留一个可读标记,真实媒体按序收集(用于内联进产品)
parts.push('[' + ins.tool.name + ':' + ins.tool.done + ']');
media.push(mediaRef.current[ins.rid] || { kind: ins.tool.kind, name: ins.tool.name, done: ins.tool.done, placeholder: true });
} else if (n.nodeType === 1 && n.querySelector && n.querySelector('img.paper-img')) {
const img = n.querySelector('img.paper-img'); // 导入 / 内联的图片(不在 pins-anchor 里)
parts.push('[图片:' + (img.alt || '内联') + ']');
media.push({ kind: 'image', src: img.getAttribute('src'), name: img.alt || '图片', own: true });
} else {
parts.push((n.textContent || '').replace(/\u00a0/g, ' '));
}
});
return { body: parts.join('\n').replace(/\n{3,}/g, '\n\n').trim(), media };
}
function collectBody() { return collectDoc().body; }
function collectMedia() { return collectDoc().media; }
// 关闭时把编辑后的内容写回(标题/正文/预览),供 localStorage 持久化
function handleClose() {
if (onPersist && note && note.id) {
const doc = collectDoc();
const preview = (doc.body || '')
.replace(/^#\s.*$/m, '').replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60);
onPersist(Object.assign({}, note, { title: title, body: doc.body, preview: preview || note.preview || '' }));
}
onClose();
}
// 找资料·引用 → 落到正文末尾的「文末出处」
function appendCitation(p, n, opts) {
opts = opts || {};
const el = canvasRef.current;
if (!el) return n;
let foot = el.querySelector('.paper-refs');
if (!foot) {
foot = document.createElement('div');
foot.className = 'paper-refs';
foot.setAttribute('contenteditable', 'false');
const h = document.createElement('div');
h.className = 'paper-refs-h';
h.textContent = '文末出处';
foot.appendChild(h);
el.appendChild(foot);
}
const num = foot.querySelectorAll('.paper-ref-row').length + 1;
const row = document.createElement('div');
row.className = 'paper-ref-row';
row.textContent = opts.source
? '[' + num + '] ' + p.name + '(' + p.prof + ')· ' + opts.source
: '[' + num + '] ' + p.name + '(' + p.prof + ')· 来源《' + p.note + '》:“' + p.snippet + '”';
foot.appendChild(row);
return num;
}
// 采纳一条灵感 → 作为一行写进纸面,并把光标停到下一行,随手就能作答
function adoptText(text) {
const el = canvasRef.current;
if (!el) return;
const mk = t => { const p = document.createElement('p'); if (t) p.textContent = t; else p.appendChild(document.createElement('br')); return p; };
const qp = mk(text), blank = mk('');
const r = savedRange.current;
let placed = false;
if (r && el.contains(r.startContainer)) {
let node = r.startContainer;
while (node && node.parentNode !== el) node = node.parentNode;
if (node && node.parentNode === el) {
el.insertBefore(qp, node.nextSibling);
el.insertBefore(blank, qp.nextSibling);
placed = true;
}
}
if (!placed) { el.appendChild(qp); el.appendChild(blank); }
el.focus();
const nr = document.createRange();
nr.setStart(blank, 0); nr.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(nr);
savedRange.current = nr.cloneRange();
}
return (
{ if (e.target === e.currentTarget && !full) handleClose(); }}>
{/* 顶栏:返回 · 本子分类 · 全屏 · 墨吞 · 完成 */}
{PCATS.map(c => (
toggle(c.id)}>
{cats.has(c.id) && }
{c.name}
))}
{/* 标题行 + 标题旁工具图标 */}
setTitle(e.target.textContent.trim() || '新的一页')}>{title}
{PAPER_TOOLS.map(tl => {
const C = tl.gicon ? GIcon[tl.gicon] : null;
return (
);
})}
{skillOpen && (
技能 · 装进本子的能力
{skills.length === 0 &&
还没装技能。到工具箱里装几个,这里就能调用。
}
{skills.map(s => {
const runnable = (typeof skillRunnable === 'function') ? skillRunnable(s) : true;
const media = (typeof SKILL_TOOLMAP === 'object') && SKILL_TOOLMAP[s.id];
return (
);
})}
)}
{/* 媒体块 / 技能结果:渲染进光标处插入的锚点 */}
{inserts.map(r => ReactDOM.createPortal(
r.kind === 'skill'
?
adoptSkill(r.rid)} onRerun={() => rerunSkill(r.rid)} onDel={() => removeInsert(r.rid)} />
: removeInsert(r.rid)} />,
r.node, String(r.rid)
))}
{inputMode === 'keyboard' &&
键盘输入 · 自动保存于本地}
{inputMode === 'voice' && (
语音输入 · 正在聆听 · 边说边落到纸上
)}
{inputMode === 'pen' && (
手写输入 · 在 iPad 或手写板上直接落笔 · 墨吞会逐渐学会你的字
)}
{TEX.map(t => (
))}
{emergeFloat && (
浮现 · 你写过的页
{emergeFloat.q}
{emergeFloat.emergeRef && emergeFloat.emergeRef.preview && (
“{emergeFloat.emergeRef.preview}”
—《{emergeFloat.emergeRef.title}》{emergeFloat.emergeRef.date ? ' · ' + emergeFloat.emergeRef.date : ''}
)}
)}
{chat &&
setChat(false)} />}
{saving &&
setSaving(false)} />}
);
}
Object.assign(window, { NewNote, PaperView, MotunChat, SaveFlow });