// 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 (
    <div className="overlay">
      <button className="ov-close" onClick={onClose}><Icon name="x" size={16} /></button>
      <div className="ov-inner">
        <h2 className="nn-title">新建笔记</h2>

        <div className="nn-tabs">
          <button className={'nn-tab' + (tab === 'photo' ? ' on' : '')} onClick={() => setTab('photo')}>
            <Icon name="camera" size={14} /> 拍照上传
          </button>
          <button className={'nn-tab' + (tab === 'text' ? ' on' : '')} onClick={() => setTab('text')}>
            <Icon name="pencil" size={14} /> 直接输入
          </button>
        </div>

        {tab === 'photo' ? (
          hasFile ? (
            <div className="dt-photo-ph" style={{ height: 200, marginBottom: 14 }}>
              <Icon name="image" size={22} /> 已选择手写照片 · 预览
            </div>
          ) : (
            <div className={'nn-drop' + (drag ? ' drag' : '')}
              onDragOver={e => { e.preventDefault(); setDrag(true); }}
              onDragLeave={() => setDrag(false)}
              onDrop={e => { e.preventDefault(); setDrag(false); setHasFile(true); }}
              onClick={() => setHasFile(true)}>
              <Icon name="camera" size={30} />
              <div className="big">把手写的一页拖到这里</div>
              <div className="small">或点击选择 · 支持 JPG / PNG / HEIC</div>
            </div>
          )
        ) : (
          <textarea className="nn-textarea" placeholder="此刻想写下的……&#10;&#10;支持 Markdown：# 标题、> 引用、- 列表"
            value={text} onChange={e => setText(e.target.value)} autoFocus />
        )}

        <div className="nn-tlbar">
          <span className="seg"><Icon name="calendar" size={13} /> 时间 <span className="v">{today}</span></span>
          <span className="seg"><Icon name="mapPin" size={13} /> 位置 <span className="v" style={{ color: 'var(--ink-3)' }}>未授权</span></span>
          <button className="loc-btn">开启位置</button>
        </div>

        <div className="nn-hist">
          <div className={'nn-hist-toggle' + (hist ? ' on' : '')} onClick={() => setHist(h => !h)}>
            <span className="box">{hist && <Icon name="check" size={11} />}</span>
            这是一段历史内容（自定义时间与地点）
          </div>
          {hist && (
            <div className="nn-hist-fields">
              <div className="nn-field">
                <label>制作时间</label>
                <input type="date" defaultValue="2021-10-14" />
              </div>
              <div className="nn-field">
                <label>地点</label>
                <input type="text" placeholder="例如：京都 · 龙安寺" />
              </div>
            </div>
          )}
        </div>

        <button className="nn-submit" disabled={!canSubmit}>
          <Icon name={canSubmit ? 'spark' : 'image'} size={15} />
          {tab === 'photo' ? (canSubmit ? '识别并保存' : '请先选择图片') : (canSubmit ? '保存这一页' : '写点什么再保存')}
        </button>
      </div>
    </div>
  );
}

// ============ 内页书写视图 ============
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 (
    <div className="pins yt">
      <div className="pins-top">
        <Icon name="film" size={14} />
        <span className="pins-name">YouTube 片段</span>
        <span className="pins-state">{vid ? '已引用片段' : '粘贴链接'}</span>
        <button className="pins-del" onClick={onDel}><Icon name="x" size={12} /></button>
      </div>
      <div className="pins-body">
        <div className="yt-urlrow">
          <input className="yt-url" value={url} spellCheck={false}
            placeholder="粘贴 YouTube 链接，如 https://youtu.be/…"
            onChange={e => setUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') parse(); }} />
          <button className="yt-parse" onClick={parse}>解析</button>
        </div>
        {err && <div className="tts-err"><Icon name="x" size={12} /> {err}</div>}

        {vid && (
          <React.Fragment>
            <div className="yt-frame">
              {embed && <iframe src={embed} title="YouTube" frameBorder="0"
                allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
                allowFullScreen></iframe>}
            </div>
            <div className="yt-times">
              <label>起 <input value={startStr} onChange={e => setStartStr(e.target.value)} placeholder="0:00" /></label>
              <label>止 <input value={endStr} onChange={e => setEndStr(e.target.value)} placeholder="（可空）" /></label>
              <button className="yt-update" onClick={update}><Icon name="refresh" size={12} /> 更新片段</button>
            </div>
            <div className="yt-hint">按时间定位已可用；字幕选段需视频开放字幕轨，随后接入。</div>
          </React.Fragment>
        )}
      </div>
      <div className="pins-foot">
        {vid ? '源自 YouTube · ' + ytFmt(ytToSec(startStr)) + (endStr ? '–' + ytFmt(ytToSec(endStr)) : ' 起') + ' · 引用即带出处' : '引用一段视频，作为这一页的素材'}
      </div>
    </div>
  );
}

// —— 墨吞 · 第四面：找灵感（帮你写下一句）—— //
// 跨域 / 跨时 / 跨地 / 接续 / 借声；每条灵感带来源，可"采纳·写进页"或"让墨吞接着写"。
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 (
    <div className="insp">
      <div className="insp-intro">
        <div className="insp-intro-t">帮你写下一句 —— 把灵感从你自己写过的东西里捞出来</div>
        {mood && <span className="insp-mood" title="心情只属于你，墨吞只读不改">心情 {mood} · 只你能改</span>}
      </div>

      <div className="insp-modes">
        {INSPIRE_MODES.map(m => (
          <button key={m.id} className={'insp-mode' + (mode === m.id ? ' on' : '')} onClick={() => setMode(m.id)} title={m.hint}>
            <Icon name={m.icon} size={13} /> {m.label}
          </button>
        ))}
      </div>
      <div className="insp-modehint">{curMode.hint}</div>

      {mode === 'cross' && tagOpts.length > 0 && (
        <div className="insp-tags">
          <span className="insp-tags-k">你的标签 · 选一个面</span>
          <div className="insp-tags-row">
            {tagOpts.map(t => (
              <button key={t} className={'insp-tagchip' + (tag === t ? ' on' : '')} onClick={() => setTag(t)}>{t}</button>
            ))}
          </div>
        </div>
      )}

      {mode === 'voice' && (
        <div className="insp-tags">
          <span className="insp-tags-k">借谁的声音</span>
          <div className="insp-tags-row">
            {VOICE_SUBS.map(v => (
              <button key={v.id} className={'insp-tagchip' + (voiceKind === v.id ? ' on' : '')} onClick={() => setVoiceKind(v.id)}>{v.label}</button>
            ))}
          </div>
        </div>
      )}

      {SEMANTIC && emerging && (
        <div className="insp-emerging"><span className="insp-emerge-dot"></span>正从你写过的页里浮现……</div>
      )}
      <div className="insp-cards">
        {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 (
            <div key={c.id} className={'insp-card' + (fromApi ? ' from-emerge' : '')}>
              <div className="insp-q">{c.q}</div>
              <button className={'insp-why' + (openRef === c.id ? ' on' : '')} disabled={!rn}
                onClick={() => rn && setOpenRef(openRef === c.id ? null : c.id)}>
                <Icon name={whyIcon[c.whyKind] || 'spark'} size={11} /> {c.why}
              </button>
              {openRef === c.id && rn && (
                <div className="insp-ref">“{rn.preview}”<i>—《{rn.title}》{rn.date ? ' · ' + rn.date : ''}{rn.city ? ' · ' + rn.city : ''}</i></div>
              )}
              {isVoice ? (
                <div className="insp-acts">
                  <button className="insp-adopt" disabled={!!voiceBusy} onClick={() => doRestyle(c, 'rewrite')}>
                    <Icon name={busyR ? 'refresh' : 'quote'} size={12} className={busyR ? 'spin' : ''} /> {busyR ? '改写中…' : '让' + c.restyle + '重写这一段'}
                  </button>
                  <button className="insp-ask" disabled={!!voiceBusy} onClick={() => doRestyle(c, 'continue')}>
                    <Icon name={busyC ? 'refresh' : 'pencil'} size={12} className={busyC ? 'spin' : ''} /> {busyC ? '续写中…' : '接着写两三句'}
                  </button>
                </div>
              ) : (
                <div className="insp-acts">
                  <button className="insp-adopt" onClick={() => onAdopt && onAdopt(c.q)}><Icon name="pencil" size={12} /> 采纳 · 写进页</button>
                  <button className="insp-ask" onClick={() => onAsk && onAsk(c.draftSeed)}><Icon name="spark" size={12} /> 让墨吞接着写</button>
                </div>
              )}
              {voiceErr && voiceErr.id === c.id && <div className="insp-voiceerr">{voiceErr.msg}</div>}
            </div>
          );
        })}
        {!cards.length && !emerging && <div className="insp-empty">这一页还太空——先写两句，墨吞就能从你的本子里给你接灵感。</div>}
      </div>
    </div>
  );
}

// —— 墨吞 · 书写时的对话助手（提问 + 找资料 · 资料带来源 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 (
    <aside className="mchat">
      <div className="mchat-head">
        <span className="motun-ava">墨</span>
        <div className="mchat-id">
          <div className="nm">墨吞</div>
          <div className="role">{live ? engineName + ' 已接入 · 边写边问' : '本地演示 · 接入引擎后真实加工'}</div>
        </div>
        <span className={'mchat-live' + (live ? ' on' : '')} title={live ? '已连接 ' + engineName : '未接入模型'}></span>
        <button className="mchat-x" onClick={onClose}><Icon name="x" size={15} /></button>
      </div>

      {/* 边写边用：问墨吞 / 找人 / 找资料 / 找灵感 */}
      <div className="mchat-tabs">
        <button className={'mctab' + (tab === 'chat' ? ' on' : '')} onClick={() => setTab('chat')}>
          <Icon name="spark" size={13} /> 问墨吞
        </button>
        <button className={'mctab' + (tab === 'people' ? ' on' : '')} onClick={() => setTab('people')}>
          <Icon name="users" size={13} /> 找人
        </button>
        <button className={'mctab' + (tab === 'sources' ? ' on' : '')} onClick={() => setTab('sources')}>
          <Icon name="quote" size={13} /> 找资料
        </button>
        <button className={'mctab core' + (tab === 'inspire' ? ' on' : '')} onClick={() => setTab('inspire')}>
          <Icon name="zap" size={13} /> 找灵感
        </button>
      </div>

      {tab === 'chat' && (
        <React.Fragment>
          <div className="mchat-scroll" ref={scrollRef}>
            {msgs.map((m, i) => (
              <div key={i} className={'mbub ' + m.role + (m.err ? ' err' : '')}>
                <div className="mbub-t">
                  {m.text}
                  {m.cite && m.cite.map(c => <sup key={c.n} className="cite-ref" title={c.title}>[{c.n}]</sup>)}
                </div>
                {m.cite && m.cite.map(c => (
                  <div key={c.n} className="src-card">
                    <span className="src-n">[{c.n}]</span>
                    <div className="src-main">
                      <div className="src-title">{c.title}</div>
                      <div className="src-meta">{c.author} · {c.year} · {c.kind}</div>
                      <div className="src-snip">“{c.snippet}”</div>
                    </div>
                    <button className="src-cite"><Icon name="quote" size={12} /> 引用</button>
                  </div>
                ))}
              </div>
            ))}
            {typing && <div className="mbub ai"><div className="mtyping"><i></i><i></i><i></i></div></div>}
          </div>

          {msgs.length <= 1 && (
            <div className="mchat-sugs">
              {open.suggestions.map(s => (
                <button key={s} onClick={() => send(s)}>{s}</button>
              ))}
            </div>
          )}

          {refs.length > 0 && (
            <details className="mchat-refs" open>
              <summary><Icon name="bookmark" size={12} /> 资料 · 引用（{refs.length}）</summary>
              {refs.map((r, i) => (
                <div key={i} className="ref-row"><span className="ref-n">[{i + 1}]</span><span className="ref-t">{r.title}<i> — {r.author}, {r.year}</i></span></div>
              ))}
            </details>
          )}

          <div className="mchat-input">
            <input value={input} onChange={e => setInput(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter') send(input); }}
              placeholder="问墨吞，让它帮你写……" />
            <button className="msend" onClick={() => send(input)} disabled={!input.trim()}><Icon name="send" size={14} /></button>
          </div>
        </React.Fragment>
      )}

      {tab === 'people' && (
        <div className="mchat-co">
          <CoCreate note={note} embedded lockMode="live" onCite={onCite} />
        </div>
      )}
      {tab === 'sources' && (
        <div className="mchat-co">
          <CoCreate note={note} embedded lockMode="past" onCite={onCite} />
        </div>
      )}
      {tab === 'inspire' && (
        <Inspire note={note} onAdopt={onAdopt} getDraft={getDraft}
          onAsk={seed => { setTab('chat'); send(seed); }} />
      )}
    </aside>
  );
}

// —— 把当前页拼成导出元数据 —— //
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 (
    <div className="exp-wrap" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="exp-card">
        <div className="exp-head">
          <div>
            <div className="exp-h">导出 · 把这一页带走</div>
            <div className="exp-sub">《{title}》 · {meta.stamp}</div>
          </div>
          <button className="sf-x" onClick={onClose}><Icon name="x" size={16} /></button>
        </div>

        <div className="exp-body">
          <div className="exp-side">
            <div className="exp-side-k">选择格式</div>
            {EXPORT_FORMATS.map(f => (
              <button key={f.id} className={'exp-fmt' + (fmt === f.id ? ' on' : '')} onClick={() => setFmt(f.id)}>
                <span className="exp-fmt-ico"><Icon name={f.icon} size={17} /></span>
                <span className="exp-fmt-main">
                  <span className="exp-fmt-name">{f.name}
                    {f.tagline && <span className={'exp-fmt-tag' + (f.tagline === '内测' ? ' beta' : '')}>{f.tagline}</span>}
                  </span>
                  <span className="exp-fmt-desc">{f.desc}</span>
                </span>
              </button>
            ))}
            <div className="exp-note">
              <Icon name="fingerprint" size={13} />
              <span>每份导出都带一枚数字凭证：内容指纹 + 时间戳，可锚定到公链（OpenTimestamps）作为存在证明。</span>
            </div>
          </div>

          <div className="exp-preview">
            <div className="exp-browser">
              <div className="exp-browser-bar">
                <span className="exp-dot"></span><span className="exp-dot"></span><span className="exp-dot"></span>
                <span className="exp-url">{fmt === 'md' ? safeName(title) + '.md' : 'popor.ink/p/' + meta.shareId}</span>
              </div>
              {fmt === 'md'
                ? <pre className="exp-md">{source}</pre>
                : <iframe className="exp-frame" title="预览" srcDoc={source} sandbox="allow-same-origin allow-scripts"></iframe>}
            </div>
          </div>
        </div>

        <div className="exp-foot">
          <div className="exp-foot-l">
            导出的网页可独立打开、发给朋友，也能成为你的个人小站的一页。
          </div>
          <button className={'exp-dl' + (done ? ' done' : '')} onClick={download}>
            <Icon name={done ? 'check' : 'download'} size={15} />
            {done ? '已下载' : (fmt === 'md' ? '下载 .md' : fmt === 'slides' ? '下载幻灯片' : '下载网页')}
          </button>
        </div>
      </div>
    </div>
  );
}

// —— 完成 → 分享 / 导出 —— //
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 <ExportSheet note={note} title={title} getContent={getContent} getMedia={getMedia} onClose={onClose} />;
  }

  return (
    <div className="saveflow" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="sf-card">
        <button className="sf-x" onClick={onClose}><Icon name="x" size={16} /></button>
        {step === 'choose' ? (
          <React.Fragment>
            <div className="sf-h">这一页，写完了</div>
            <div className="sf-sub">《{title}》 · {stamp}</div>
            <div className="sf-opts">
              <button className="sf-opt" onClick={() => setStep('cert')}>
                <span className="sf-ico"><Icon name="share" size={20} /></span>
                <span className="sf-name">分享</span>
                <span className="sf-desc">连同数字凭证，发给一个人或公开</span>
              </button>
              <button className="sf-opt" onClick={() => setStep('export')}>
                <span className="sf-ico"><Icon name="download" size={20} /></span>
                <span className="sf-name">导出</span>
                <span className="sf-desc">存成网页 / 幻灯片 / Markdown，永久带走</span>
              </button>
            </div>
            <div className="sf-foot">无论哪种，都会为这一页生成一枚数字凭证。</div>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <div className="sf-done"><Icon name="check" size={18} /> 已生成可分享的凭证</div>
            <div className="evidence sf-evidence">
              <div className="ev-seal"><Icon name="fingerprint" size={20} /></div>
              <div className="ev-body">
                <div className="ev-title">数字凭证 · Proof of Authorship</div>
                <div className="ev-row"><span className="ev-k">页面</span><span className="ev-v">{title}</span></div>
                <div className="ev-row"><span className="ev-k">确权时间</span><span className="ev-v mono">{stamp}</span></div>
                <div className="ev-row"><span className="ev-k">内容指纹</span><span className="ev-v mono hash">{hash}</span></div>
                <div className="ev-row"><span className="ev-k">分享链接</span><span className="ev-v mono" style={{ color: 'var(--sea)' }}>popor.ink/p/{hash.replace(/\s/g, '').slice(0, 8)}</span></div>
                <div className="ev-note">指纹经 SHA-256 计算（此处为演示哈希），可锚定到公链时间戳，作为这页文字、图像与创意属于你的数字证据。</div>
              </div>
              <div className="ev-chop">钤<br/>印</div>
            </div>
            <div className="sf-twin">
              <button className="sf-back ghost" onClick={() => setStep('export')}><Icon name="download" size={14} /> 改为导出文件</button>
              <button className="sf-back" onClick={onClose}>完成</button>
            </div>
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

// —— 内嵌生成块（墨吞工具产物，落在正文光标处）—— //
// 媒体块支持「AI 生成 ↔ 用我的素材」；TTS 走独立块（合成 / 克隆 / 上传）。
const OWN_ACCEPT = { image: 'image/*', video: 'video/*', audio: 'audio/*' };
function OwnMedia({ kind, url, type }) {
  if (!url) return null;
  if (kind === 'image') return <img className="own-media" src={url} alt="我的素材" />;
  if (kind === 'video') return <video className="own-media" src={url} controls />;
  if (kind === 'audio') return <audio className="own-audio" src={url} controls />;
  return null;
}

function InsertBlock({ tool, status, title, onDel, rid, onMedia }) {
  if (tool.kind === 'tts') return <TtsBlock tool={tool} title={title} onDel={onDel} rid={rid} onMedia={onMedia} />;
  if (tool.kind === 'youtube') return <YoutubeBlock title={title} onDel={onDel} rid={rid} onMedia={onMedia} />;

  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 (
    <div className={'pins' + (status === 'gen' && mode === 'ai' ? ' gen' : '')}>
      <div className="pins-top">
        {tool.gicon ? React.createElement(GIcon[tool.gicon], { size: 14 }) : <Icon name={tool.icon} size={14} />}
        <span className="pins-name">{tool.name}</span>
        {isMedia && (
          <div className="pins-seg">
            <button className={mode === 'ai' ? 'on' : ''} onClick={() => setMode('ai')}><Icon name="spark" size={11} /> AI 生成</button>
            <button className={mode === 'own' ? 'on' : ''} onClick={() => setMode('own')}><Icon name="upload" size={11} /> 用我的</button>
          </div>
        )}
        {mode === 'ai' && (status === 'gen'
          ? <span className="pins-state"><Icon name="refresh" size={11} className="spin" /> 生成中</span>
          : <span className="pins-state done"><Icon name="check" size={11} /> {tool.done}</span>)}
        <button className="pins-del" onClick={onDel}><Icon name="x" size={12} /></button>
      </div>

      <div className="pins-body">
        {mode === 'ai'
          ? (status === 'gen' ? <div className="gen-skel" /> : <ResultBody kind={tool.kind} />)
          : (ownUrl
              ? <OwnMedia kind={tool.kind} url={ownUrl} type={ownType} />
              : <div className="own-drop" onClick={() => fileRef.current && fileRef.current.click()}
                  onDragOver={e => e.preventDefault()} onDrop={onDrop}>
                  <Icon name="upload" size={20} />
                  <div className="own-drop-t">拖入或点击，放上你自己的{tool.kind === 'image' ? '图片' : tool.kind === 'video' ? '视频' : '音频'}</div>
                  <input ref={fileRef} type="file" accept={OWN_ACCEPT[tool.kind]} hidden onChange={onPick} />
                </div>)}
      </div>

      <div className="pins-foot">
        {mode === 'ai' ? '源自《' + title + '》 · 墨吞生成 · 已插入光标处' : '你的素材 · 已插入这一页'}
      </div>
    </div>
  );
}

// —— 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 (
    <div className="pins tts">
      <div className="pins-top">
        <Icon name="mic" size={14} />
        <span className="pins-name">配音 · 文本转语音</span>
        <span className={'tts-eng' + (source === 'record' ? ' free' : (hasTts() ? ' on' : ''))} onClick={() => setSetup(s => !s)}>
          {source === 'record' ? '作者朗读 · 无需引擎'
            : hasTts() ? ((TTS_PROVIDERS.find(p => p.id === prov) || {}).name || prov) : '未接入语音引擎'} <Icon name="settings" size={11} />
        </span>
        <button className="pins-del" onClick={onDel}><Icon name="x" size={12} /></button>
      </div>

      {setup && (source === 'ai' || source === 'clone') && (
        <div className="tts-setup">
          <div className="tts-provs">
            {TTS_PROVIDERS.map(p => (
              <button key={p.id} className={'tts-prov' + (prov === p.id ? ' on' : '')} onClick={() => { setProv(p.id); setKeyInput(getTtsKey(p.id)); setVoice(ttsVoiceList(p.id)[0].id); }}>
                <b>{p.name}</b><i>{p.sub}</i>
              </button>
            ))}
          </div>
          <input className="ai-key" type="password" value={keyInput} placeholder={(TTS_PROVIDERS.find(p => p.id === prov) || {}).keyHint}
            onChange={e => setKeyInput(e.target.value)} spellCheck={false} />
          <button className="tts-savekey" onClick={saveSetup}><Icon name="save" size={12} /> 保存密钥</button>
          <div className="ai-custom-hint">国内可选 千问 Qwen-TTS 或 CosyVoice（通义/百炼），国外推荐 ElevenLabs；浏览器直连被拦时，在墨吞·进阶填代理地址。</div>
        </div>
      )}

      <div className="pins-body">
        <textarea className="tts-text" value={text} onChange={e => setText(e.target.value)}
          placeholder={source === 'record' ? '把要读的文字写在这里当提词稿（可留空）……' : '把要朗读的文字写在这里……'} />

        <div className="tts-source">
          <button className={source === 'record' ? 'on' : ''} onClick={() => setSource('record')}><Icon name="mic" size={12} /> 作者朗读</button>
          <button className={source === 'ai' ? 'on' : ''} onClick={() => setSource('ai')}><Icon name="spark" size={12} /> AI 音色</button>
          <button className={source === 'clone' ? 'on' : ''} onClick={() => setSource('clone')}><Icon name="spark" size={12} /> 克隆我声{cloned ? ' ✓' : ''}</button>
          <button className={source === 'own' ? 'on' : ''} onClick={() => setSource('own')}><Icon name="upload" size={12} /> 上传音频</button>
        </div>

        {source === 'record' && (
          <div className="tts-clone">
            <div className="tts-rec">
              {!recording
                ? <button className="tts-recbtn" onClick={startRec}><Icon name="mic" size={13} /> {audioUrl ? '重录一遍' : '录下你的朗读'}</button>
                : <button className="tts-recbtn rec" onClick={stopRec}><span className="rec-dot" /> 停止录音</button>}
            </div>
            <div className="tts-rec-hint">用你自己的声音读这一页 —— 不需要任何引擎，录完即内联进作品。作者的声音，往往比文字更有力。</div>
          </div>
        )}

        {source === 'ai' && (
          <div className="tts-voices">
            {ttsVoiceList(prov).map(v => (
              <button key={v.id} className={'tts-voice' + (voice === v.id ? ' on' : '')} onClick={() => { setVoice(v.id); setTtsVoice(v.id); }}>{v.name}</button>
            ))}
          </div>
        )}
        {source === 'clone' && (
          <div className="tts-clone">
            {cloned
              ? <div className="tts-clone-have"><Icon name="check" size={13} /> 已就绪：{cloned.name}　<button onClick={() => { setClonedVoice(null); setClonedV(null); }}>重录</button></div>
              : <React.Fragment>
                  <div className="tts-rec">
                    {!recording
                      ? <button className="tts-recbtn" onClick={startRec}><Icon name="mic" size={13} /> 录一段你的声音（约 30 秒）</button>
                      : <button className="tts-recbtn rec" onClick={stopRec}><span className="rec-dot" /> 停止录音</button>}
                    {recUrl && <audio className="own-audio" src={recUrl} controls />}
                  </div>
                  {recUrl && <button className="tts-savekey" disabled={busy} onClick={doClone}><Icon name="spark" size={12} /> {busy ? '生成音色中…' : '用这段创建我的音色'}</button>}
                </React.Fragment>}
          </div>
        )}
        {source === 'own' && (
          <div className="tts-own">
            <button className="tts-recbtn" onClick={() => fileRef.current && fileRef.current.click()}><Icon name="upload" size={13} /> 选择你已有的音频</button>
            <input ref={fileRef} type="file" accept="audio/*" hidden onChange={onOwnAudio} />
          </div>
        )}

        {(source === 'ai' || source === 'clone') && (
          <button className="tts-synth" disabled={!text.trim() || busy} onClick={synth}>
            {busy ? <React.Fragment><Icon name="refresh" size={13} className="spin" /> 合成中…</React.Fragment>
                  : <React.Fragment><Icon name="play" size={13} /> 合成语音</React.Fragment>}
          </button>
        )}

        {err && <div className="tts-err"><Icon name="x" size={12} /> {err}</div>}
        {audioUrl && <audio className="own-audio result" src={audioUrl} controls />}
      </div>

      <div className="pins-foot">源自《{title}》 · {source === 'record' ? '作者朗读 · 你的声音' : source === 'own' ? '你的音频' : '文本 → 语音'} · 内联进这一页</div>
    </div>
  );
}

// —— 调用技能：墨吞引擎跑出结果，落到正文 —— //
function SkillBlock({ item, engineName, onAdopt, onRerun, onDel }) {
  const s = item.skill;
  return (
    <div className={'pins skill ' + item.status}>
      <div className="pins-top">
        <Icon name={s.icon || 'spark'} size={14} />
        <span className="pins-name">{s.name}</span>
        {item.status === 'gen' && <span className="pins-state"><Icon name="refresh" size={11} className="spin" /> {engineName} 运行中</span>}
        {item.status === 'done' && <span className="pins-state done"><Icon name="check" size={11} /> 由 {engineName} 生成</span>}
        {item.status === 'error' && <span className="pins-state err"><Icon name="x" size={11} /> 出错</span>}
        {item.status === 'needkey' && <span className="pins-state err">未接入引擎</span>}
        <button className="pins-del" onClick={onDel}><Icon name="x" size={12} /></button>
      </div>
      <div className="pins-body">
        {item.status === 'gen' && <div className="gen-skel" />}
        {item.status === 'done' && <div className="skill-out">{item.text}</div>}
        {item.status === 'error' && <div className="tts-err"><Icon name="x" size={12} /> {item.text}</div>}
        {item.status === 'needkey' && (
          <div className="skill-needkey">
            这是文字技能，需要墨吞接入一个引擎才能运行。到顶栏「<b>墨吞</b>」接入 DeepSeek / Gemini / Grok / ChatGPT / 千问 任意一家，再回来调用。
          </div>
        )}
      </div>
      {item.status === 'done' && (
        <div className="skill-acts">
          <button className="skill-adopt" onClick={onAdopt}><Icon name="check" size={12} /> 采用 · 并入正文</button>
          <button className="skill-rerun" onClick={onRerun}><Icon name="refresh" size={12} /> 重跑</button>
          <span className="skill-foot">{s.custom ? '我自制' : (s.by === '官方' ? '官方技能' : (s.byName || s.by))} · 跑在你接入的引擎</span>
        </div>
      )}
    </div>
  );
}

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 (
    <div className={'paperview' + (full ? ' full' : '')} onClick={e => { if (e.target === e.currentTarget && !full) handleClose(); }}>
      <div className={'paper-shell' + (full ? ' full' : '')}>
        {/* 顶栏：返回 · 本子分类 · 全屏 · 墨吞 · 完成 */}
        <div className="paper-bar">
          <button className="pb-back" onClick={handleClose}><Icon name="arrowL" size={14} /> 返回</button>
          <div className="input-modes top">
            <button className={'imode' + (inputMode === 'keyboard' ? ' on' : '')}
              title="键盘输入" onClick={() => setInputMode('keyboard')}>
              <Icon name="keyboard" size={14} /> 键盘
            </button>
            <button className={'imode' + (inputMode === 'voice' ? ' on' : '')}
              title="语音输入" onClick={() => setInputMode('voice')}>
              <Icon name="mic" size={14} /> 语音
            </button>
            <button className={'imode' + (inputMode === 'pen' ? ' on' : '')}
              title="手写输入" onClick={() => setInputMode('pen')}>
              <Icon name="penTablet" size={14} /> 手写
            </button>
          </div>
          <div className="paper-cats">
            {PCATS.map(c => (
              <span key={c.id} className={'pcat' + (cats.has(c.id) ? ' on' : '')} onClick={() => toggle(c.id)}>
                <span className="box">{cats.has(c.id) && <Icon name="check" size={9} />}</span>
                <Icon name={c.icon} size={12} className="ico" />
                {c.name}
              </span>
            ))}
          </div>
          <div className="pb-right">
            <button className={'pb-icon' + (chat ? ' on' : '')} title="墨吞" onClick={() => setChat(c => !c)}><Icon name="spark" size={16} /></button>
            <button className="pb-icon" title={full ? '退出全屏' : '全屏书写'} onClick={() => setFull(f => !f)}><Icon name={full ? 'minimize' : 'maximize'} size={16} /></button>
            <button className="pb-done" onClick={() => setSaving(true)}><Icon name="check" size={14} /> 完成</button>
          </div>
        </div>

        <div className="paper-main">
          <div className="paper-sheet">
            {/* 标题行 + 标题旁工具图标 */}
            <div className="paper-titlebar">
              <h1 className="paper-title" contentEditable suppressContentEditableWarning spellCheck={false}
                onBlur={e => setTitle(e.target.textContent.trim() || '新的一页')}>{title}</h1>
              <div className="paper-tools">
                {PAPER_TOOLS.map(tl => {
                  const C = tl.gicon ? GIcon[tl.gicon] : null;
                  return (
                    <button key={tl.id} className={'ptool' + (busy === tl.id ? ' busy' : '')} disabled={!!busy}
                      title={tl.name + ' · 插入到光标处'} onClick={() => runTool(tl)}>
                      {C ? <C size={17} /> : <Icon name={tl.icon} size={17} />}
                    </button>
                  );
                })}
                <span className="ptool-div"></span>
                <div className="ptool-skill-wrap">
                  <button className={'ptool skill' + (skillOpen ? ' on' : '')} title="技能 · 调用墨吞引擎"
                    onClick={() => { setSkills(typeof getInstalledSkills === 'function' ? getInstalledSkills() : []); setSkillOpen(o => !o); }}>
                    <Icon name="spark" size={16} /> <span className="ptool-skill-l">技能</span>
                  </button>
                  {skillOpen && (
                    <div className="skill-menu">
                      <div className="skill-menu-h">
                        <span>技能 · 装进本子的能力</span>
                        <button onClick={() => { setSkillOpen(false); if (onOpenStore) onOpenStore(); }}>去工具箱 ›</button>
                      </div>
                      {skills.length === 0 && <div className="skill-menu-empty">还没装技能。到工具箱里装几个，这里就能调用。</div>}
                      {skills.map(s => {
                        const runnable = (typeof skillRunnable === 'function') ? skillRunnable(s) : true;
                        const media = (typeof SKILL_TOOLMAP === 'object') && SKILL_TOOLMAP[s.id];
                        return (
                          <button key={s.id} className="skill-menu-item" disabled={!runnable} onClick={() => runSkill(s)}>
                            <Icon name={s.icon || 'spark'} size={15} />
                            <span className="smi-n">{s.name}</span>
                            {s.custom ? <span className="smi-tag mine">我的</span>
                              : media ? <span className="smi-tag media">影像</span>
                              : <span className="smi-tag">文字</span>}
                          </button>
                        );
                      })}
                    </div>
                  )}
                </div>
              </div>
            </div>

            <div className="paper-canvas" data-tex={tex} ref={canvasRef}
              contentEditable suppressContentEditableWarning spellCheck={false}
              onInput={onCanvasInput} onKeyUp={saveSel} onMouseUp={saveSel} onFocus={saveSel}></div>

            {/* 媒体块 / 技能结果：渲染进光标处插入的锚点 */}
            {inserts.map(r => ReactDOM.createPortal(
              r.kind === 'skill'
                ? <SkillBlock item={r} engineName={(typeof activeProviderName === 'function') ? activeProviderName() : '墨吞'}
                    onAdopt={() => adoptSkill(r.rid)} onRerun={() => rerunSkill(r.rid)} onDel={() => removeInsert(r.rid)} />
                : <InsertBlock tool={r.tool} status={r.status} title={title} rid={r.rid} onMedia={registerMedia} onDel={() => removeInsert(r.rid)} />,
              r.node, String(r.rid)
            ))}

            <div className="paper-foot">
              {inputMode === 'keyboard' && <span className="imode-hint"><Icon name="keyboard" size={13} /> 键盘输入 · 自动保存于本地</span>}
              {inputMode === 'voice' && (
                <span className="imode-hint voice">
                  <span className="rec-wave"><i></i><i></i><i></i><i></i><i></i></span>
                  语音输入 · 正在聆听 · 边说边落到纸上
                </span>
              )}
              {inputMode === 'pen' && (
                <span className="imode-hint"><Icon name="penTablet" size={13} /> 手写输入 · 在 iPad 或手写板上直接落笔 · 墨吞会逐渐学会你的字</span>
              )}
              <div className="tex-switch">
                {TEX.map(t => (
                  <button key={t.id} className={tex === t.id ? 'on' : ''} title={t.label} onClick={() => setTex(t.id)}>
                    <Icon name={t.icon} size={15} />
                  </button>
                ))}
              </div>
            </div>
          </div>

          {emergeFloat && (
            <div className="emerge-float" key={emergeFloat.id}>
              <div className="emerge-float-k"><Icon name="spark" size={12} /> 浮现 · 你写过的页</div>
              <div className="emerge-float-q">{emergeFloat.q}</div>
              {emergeFloat.emergeRef && emergeFloat.emergeRef.preview && (
                <div className="emerge-float-ref">“{emergeFloat.emergeRef.preview}”
                  <i>—《{emergeFloat.emergeRef.title}》{emergeFloat.emergeRef.date ? ' · ' + emergeFloat.emergeRef.date : ''}</i>
                </div>
              )}
              <div className="emerge-float-acts">
                <button onClick={() => { adoptText(emergeFloat.q); setEmergeFloat(null); }}><Icon name="pencil" size={12} /> 接上这一页</button>
                <button className="ghost" onClick={() => setEmergeFloat(null)}>不用了</button>
              </div>
            </div>
          )}

          {chat && <MotunChat note={note} getDraft={collectBody} onCite={appendCitation} onAdopt={adoptText} onClose={() => setChat(false)} />}
        </div>
      </div>

      {saving && <SaveFlow note={note} title={title} getContent={collectBody} getMedia={collectMedia} onClose={() => setSaving(false)} />}
    </div>
  );
}

Object.assign(window, { NewNote, PaperView, MotunChat, SaveFlow });
