/* =====================================================================
   components.jsx — 展示层
   Bubble（气泡）/ Banner（结构层横幅）/ Reader（正文 + 右侧栏 + 连接线）
   依赖：React, bandu.markdown
   导出：bandu.ui
   ===================================================================== */

window.bandu = window.bandu || {};

(function (bandu) {

const { useState, useMemo, useRef, useLayoutEffect, useEffect, useCallback } = React;
const { Block } = bandu.markdown;

const VOICE_LABEL = { mentor: "导师", student: "朋友", self: "自己" };
const TYPE_LABEL = { fun_commentary: "趣评", roast: "吐槽" };
// presentation_form 是写批注时的体例约束，不是关于内容的信息 —— 读者看到的文字
// 本身就已经是那个形状了。所以默认不显示，鼠标停在气泡上才露出来，供校对用。
// 万一遇到没收录的取值，宁可露原文也不要显示 undefined。
const FORM_LABEL = {
  short_paragraph: "短评",
  scene_then_point: "先场景后点题",
  mini_dialogue: "小对话",
  contrast_pair: "对照",
  arrow_chain: "推演链",
  core_question: "核心提问",
  bullet_breakdown: "要点拆解",
};

// Commentary text carries real newlines that are structural: arrow_chain nodes,
// dialogue turns, list items. Rendered with white-space:pre-line they would be
// exactly one line-height apart — indistinguishable from an accidental wrap.
// Splitting into blocks lets the break carry its own, wider gap.
function RichText({ text, className }) {
  const lines = String(text || "").split("\n").map((l) => l.trim()).filter(Boolean);
  if (lines.length < 2) return <div className={className}>{text}</div>;
  return (
    <div className={className}>
      {lines.map((l, i) => <p key={i} className="ln">{l}</p>)}
    </div>
  );
}

function Bubble({ bubble, style, active, dim, onEnter, onLeave, elRef, className, chat }) {
  // chat.jsx 在本文件之后加载，所以在渲染时才取 InlineChat，不能在模块顶层取
  const InlineChat = chat && bandu.chat && bandu.chat.InlineChat;
  return (
    <div
      ref={elRef}
      className={`bubble ${bubble.voice} ${active ? "active" : ""} ${dim ? "dim" : ""} ${className || ""}`}
      style={style}
      onMouseEnter={onEnter}
      onMouseLeave={onLeave}
    >
      <div className="head">
        <span className="who">{VOICE_LABEL[bubble.voice] || bubble.voice}</span>
        <span>{TYPE_LABEL[bubble.commentType] || bubble.commentType}</span>
        <span className="form">{FORM_LABEL[bubble.form] || bubble.form}</span>
      </div>
      <RichText text={bubble.text} className="text" />
      {InlineChat && (
        <InlineChat
          bubble={bubble}
          bookId={chat.bookId}
          initial={chat.threads ? chat.threads.get(bubble.id) : null}
        />
      )}
    </div>
  );
}

// 读者自己的笔记气泡：头部带二段确认的删除钮，下方对话框可 @伴读点评或纯补充
function NoteBubble({ note, beatId, active, dim, onEnter, onLeave, elRef, chat }) {
  const InlineChat = chat && bandu.chat && bandu.chat.InlineChat;
  const [confirm, setConfirm] = useState(false);
  const [err, setErr] = useState(null);
  const del = async () => {
    if (!confirm) { setConfirm(true); setErr(null); return; }
    try {
      await bandu.chat.deleteNote(chat.bookId, beatId);
      bandu.chat.forgetThread(chat.bookId, note.commentId);
      onLeave(); // 元素卸载后不会再触发 mouseleave，需手动清除悬停状态
      chat.onNoteDeleted(beatId);
    } catch (e) { setErr(e.message); setConfirm(false); }
  };
  return (
    <div
      ref={elRef}
      className={`bubble self ${active ? "active" : ""} ${dim ? "dim" : ""}`}
      onMouseEnter={onEnter}
      onMouseLeave={onLeave}
    >
      <div className="head">
        <span className="who">自己</span>
        <span>笔记</span>
        {/* 删除不可逆：第一下变红「确认删除」，第二下才真删；移开鼠标就还原 */}
        <button
          type="button"
          className={`note-del ${confirm ? "confirm" : ""}`}
          onClick={del}
          onMouseLeave={() => setConfirm(false)}
        >
          {confirm ? "确认删除" : "删除"}
        </button>
      </div>
      <RichText text={note.text} className="text" />
      {err && <div className="chat-err">{err}</div>}
      {InlineChat && (
        <InlineChat
          bubble={{ id: note.commentId, voice: "self" }}
          bookId={chat.bookId}
          initial={note.threadId != null ? { thread_id: note.threadId, messages: note.messages } : null}
          voiceMode="optional"
        />
      )}
    </div>
  );
}

// 写笔记的编辑气泡：正文 + 三态 @（保存时请点评/不@）。@ 时点评在预览区流式
// 出现，完成后整体变成 NoteBubble。保存失败不清草稿。
const NOTE_AT_CYCLE = { none: "mentor", mentor: "student", student: "none" };

function NoteEditor({ beatId, chat, onClose, elRef }) {
  const [text, setText] = useState("");
  const [askVoice, setAskVoice] = useState("none");
  const [busy, setBusy] = useState(false);
  const [preview, setPreview] = useState("");
  const [err, setErr] = useState(null);
  const metaRef = useRef(null);
  const previewRef = useRef("");

  const commit = (t) => {
    const note = metaRef.current;
    chat.onNoteCreated(beatId, {
      commentId: note.comment_id, threadId: note.thread_id, text: t,
      messages: previewRef.current
        ? [{ role: "assistant", content: previewRef.current, voice: askVoice }]
        : [],
    });
    onClose();
  };

  const save = async () => {
    const t = text.trim();
    if (!t || busy) return;
    setBusy(true); setErr(null);
    metaRef.current = null; previewRef.current = "";
    try {
      await bandu.chat.createNote(
        chat.bookId, beatId, t,
        askVoice === "none" ? null : askVoice,
        (note) => { metaRef.current = note; },
        (delta) => { previewRef.current += delta; setPreview(previewRef.current); }
      );
      commit(t);
    } catch (e) {
      // 元数据已到 = 笔记本身建成，只是点评断了：照常落地，别让用户撞 409
      if (metaRef.current) { commit(t); return; }
      setErr(e.message); setBusy(false); setPreview("");
    }
  };

  return (
    <div ref={elRef} className="bubble self note-editor">
      <div className="head"><span className="who">自己</span><span>写笔记</span></div>
      <textarea
        value={text}
        rows={3}
        autoFocus
        disabled={busy}
        placeholder="写下你的想法…"
        onChange={(e) => setText(e.target.value)}
      />
      {preview && (
        <div className="note-preview">
          <span className={`chat-tag ${askVoice}`}>{VOICE_LABEL[askVoice]}</span>
          {preview}
        </div>
      )}
      {err && <div className="chat-err">{err}</div>}
      <div className="note-editor-actions">
        <button
          type="button"
          className={`ichat-at ${askVoice}`}
          title="保存时请这位伴读点评（点击切换）"
          disabled={busy}
          onClick={() => setAskVoice((v) => NOTE_AT_CYCLE[v])}
        >
          {askVoice === "none" ? "笔记" : `@${VOICE_LABEL[askVoice]}`}
        </button>
        <div className="spacer" />
        <button className="btn" disabled={busy} onClick={onClose}>取消</button>
        <button className="btn primary" disabled={busy || !text.trim()} onClick={save}>
          {busy ? "…" : "保存"}
        </button>
      </div>
    </div>
  );
}

const DEPTH_LABEL = ["全书", "本章", "本节"];

function Banner({ kind, unit, depth, comment, chat }) {
  // chat.jsx 在本文件之后加载，渲染时才取（同 Bubble 的做法）
  const InlineChat = chat && bandu.chat && bandu.chat.InlineChat;
  if (!comment) return null;
  const hook = kind === "open" ? comment.opening_hook : comment.closing_question;
  if (!hook || !hook.text) return null;
  // 与后端 comment-ids.mjs 同一条规则：结构层 hook 的 id 是 <target_id>:open / :close
  const commentId = hook.comment_id || `${unit.id}:${kind}`;
  const scope = DEPTH_LABEL[depth] || "";
  const title = unit.title_zh || unit.title || "";
  const label = `${kind === "open" ? "开场" : "收束"} · ${scope}${title ? " · " + title : ""}`;
  return (
    <div className={`banner d${depth} ${kind === "close" ? "close" : ""}`}>
      <div className="label">{label}</div>
      <RichText text={hook.text} className="body" />
      {InlineChat && (
        <InlineChat
          bubble={{ id: commentId, voice: hook.voice || "mentor" }}
          bookId={chat.bookId}
          initial={chat.threads ? chat.threads.get(commentId) : null}
          voiceMode="ask"
        />
      )}
    </div>
  );
}

// Bubbles float below the sticky topbar, not below the viewport edge.
function guardLine() {
  const bar = document.querySelector(".topbar");
  return (bar ? bar.getBoundingClientRect().bottom : 0) + 14;
}

function Reader({ doc, voices, progress, onUnlockNext, chat }) {
  const layoutRef = useRef(null);
  const articleRef = useRef(null);
  const gutterRef = useRef(null);
  const beatEls = useRef(new Map());
  const bubbleEls = useRef(new Map());
  const groupEls = useRef(new Map());
  const [positions, setPositions] = useState({});
  const [inlineMode, setInlineMode] = useState(() => window.innerWidth < 1180);
  const [hoverBubble, setHoverBubble] = useState(null);
  const [hoverBeat, setHoverBeat] = useState(null);
  const [draftBeat, setDraftBeat] = useState(null); // 正在写笔记的 beat
  const notes = (chat && chat.notes) || null;
  const bookId = chat ? chat.bookId : null;

  // beat 的文档序：笔记/草稿伪气泡并入列表后要按这个排序，
  // 否则只带笔记的 beat 会被排到 slot 队尾，平铺布局全乱
  const beatIndex = useMemo(() => {
    const m = new Map();
    doc.groups.forEach((g) => { if (g.ownerId && !m.has(g.ownerId)) m.set(g.ownerId, m.size); });
    return m;
  }, [doc]);

  const visibleBubbles = useMemo(() => {
    // 渐进解锁：未解锁 beat 的一切气泡（批注/笔记/写笔记入口）都不进列表，
    // 否则会在右栏留下隐形的 slot 占位
    const open = (beatId) => !progress || progress.unlockedSet.has(beatId);
    const list = doc.bubbles.filter((b) => voices[b.voice] && open(b.beatId));
    if (notes && voices.self)
      for (const [beatId, note] of notes)
        if (open(beatId)) list.push({ id: note.commentId, beatId, voice: "self", note });
    // 草稿不受「自己」开关控制：正在写就得看得见
    if (draftBeat) list.push({ id: `draft:${draftBeat}`, beatId: draftBeat, voice: "self", draft: true });
    // 写笔记入口也是伪气泡：挂在该 beat 的评论下方，悬停原文才浮现（见 .write-note）。
    // 窄屏内联没有悬停语义，入口只在宽屏提供。
    if (!inlineMode && bookId != null && notes)
      for (const beatId of beatIndex.keys())
        if (open(beatId) && !notes.has(beatId) && draftBeat !== beatId)
          list.push({ id: `write:${beatId}`, beatId, voice: "self", write: true });
    // sort 稳定：同 beat 内保持 批注 → 笔记 → 草稿/入口 的插入序
    return list.sort((a, b) => (beatIndex.get(a.beatId) ?? 1e9) - (beatIndex.get(b.beatId) ?? 1e9));
  }, [doc, voices, notes, draftBeat, beatIndex, inlineMode, bookId, progress]);

  useEffect(() => {
    const onResize = () => setInlineMode(window.innerWidth < 1180);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  // Pass 1 (layout): measure each beat, then lay out one *slot* per beat in the
  // gutter. Slots tile the gutter top to bottom without gaps or overlaps, and a
  // beat's bubbles live inside its slot as a single sticky group.
  //
  // Tiling is what makes overlap impossible: a slot cannot start before the
  // previous one ends, and sticky positioning is contained by its own slot, so
  // no bubble can ever escape into a neighbour's territory. The stacking that
  // used to be enforced by JS on every scroll is now a property of the layout.
  const relayout = useCallback(() => {
    if (inlineMode || !articleRef.current) return;
    const base = articleRef.current.getBoundingClientRect().top + window.scrollY;

    // bubbles grouped by beat, in document order
    const order = [];
    const byBeat = new Map();
    for (const b of visibleBubbles) {
      if (!byBeat.has(b.beatId)) { byBeat.set(b.beatId, []); order.push(b.beatId); }
      byBeat.get(b.beatId).push(b);
    }

    const next = {};
    let cursor = 0;
    for (const beatId of order) {
      const beatEl = beatEls.current.get(beatId);
      const groupEl = groupEls.current.get(beatId);
      if (!beatEl) continue;
      const r = beatEl.getBoundingClientRect();
      const anchor = r.top + window.scrollY - base;
      const top = Math.max(anchor, cursor);
      // The slot runs to the end of its beat, but is never shorter than the
      // bubbles it holds — otherwise the group would be clipped.
      const groupHeight = groupEl ? groupEl.offsetHeight : 0;
      const span = Math.max(anchor + r.height - top, groupHeight);
      next[beatId] = { top, anchor, span, groupHeight };
      cursor = top + span;              // 下一个 slot 从这里开始，绝不重叠
    }

    setPositions((prev) => {
      const ks = Object.keys(next);
      const same = ks.length === Object.keys(prev).length &&
        ks.every((k) => prev[k] &&
          Math.abs(prev[k].top - next[k].top) < 0.5 &&
          Math.abs(prev[k].span - next[k].span) < 0.5);
      return same ? prev : next;
    });
  }, [visibleBubbles, inlineMode]);

  useLayoutEffect(relayout);

  useEffect(() => {
    if (!articleRef.current) return;
    const ro = new ResizeObserver(relayout);
    ro.observe(articleRef.current);
    window.addEventListener("resize", relayout);
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(relayout);
    return () => { ro.disconnect(); window.removeEventListener("resize", relayout); };
  }, [relayout]);

  // 气泡内的对话区（载入历史、流式变长、展开收起）改变的是气泡组自己的高度，
  // 不经过 Reader 的 render，也不动正文列的尺寸——上面那个 RO 看不见。
  // 所以每个组元素单独观察；回调经 ref 取最新的 relayout，RO 本身只建一次。
  const relayoutRef = useRef(relayout);
  useEffect(() => { relayoutRef.current = relayout; }, [relayout]);
  const groupRO = useRef(null);
  if (!groupRO.current) groupRO.current = new ResizeObserver(() => relayoutRef.current());
  useEffect(() => () => groupRO.current.disconnect(), []);

  // There is no scroll-driven positioning pass, on purpose. Floating used to be
  // computed in a scroll handler and applied through React state, but the browser
  // composites the scroll before React re-renders, so the bubble always arrived a
  // frame or more late — that lag was the visible drift. Sticky positioning is
  // resolved by the compositor and cannot lag by construction, so pinning lives
  // in CSS and JS only computes resting geometry, which changes on resize.

  // Connectors are the one thing sticky cannot do for us: a path's two ends move
  // differently once its bubble pins, so no single element can be sticky for
  // both. They are redrawn from the bubbles' real measured positions, throttled
  // to one rAF and written straight to the DOM — no React state, so the sticky
  // bubbles never re-render and cannot be knocked off the compositor path.
  // A hairline may trail the bubble by a frame during a fast flick; the bubble
  // itself cannot, which is the part that was visible.
  const pathEls = useRef(new Map());
  useEffect(() => {
    if (inlineMode) return;
    let queued = false;
    const draw = () => {
      queued = false;
      const gut = gutterRef.current;
      if (!gut) return;
      const gTop = gut.getBoundingClientRect().top;
      // pathEls 按 bubble id 存，positions 按 beat id 存 —— 所以要顺着 beatId 取，
      // 不能拿 bubble id 去查 positions（那样每条都取不到，线就永远停在静止位置）。
      for (const [id, rec] of pathEls.current) {
        const { el: path, beatId } = rec;
        const p = positions[beatId];
        const el = bubbleEls.current.get(id);
        if (!p || !el || !path) continue;
        const y1 = p.anchor + 10;
        const y2 = el.getBoundingClientRect().top - gTop + 24;
        path.setAttribute("d", `M -40 ${y1} C -18 ${y1}, -18 ${y2}, 0 ${y2}`);
      }
    };
    const onScroll = () => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(draw);
    };
    draw();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [positions, inlineMode, visibleBubbles]);

  // Where a bubble pins, in viewport coordinates. Measured once (and on resize),
  // never on scroll: the topbar is sticky so its height is scroll-invariant.
  const [stickyTop, setStickyTop] = useState(62);
  useLayoutEffect(() => {
    const measure = () => setStickyTop(guardLine());
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, []);

  // Hovering the prose highlights the same beat that hovering its bubble does.
  const activeBeat = hoverBubble ? hoverBubble.beatId : hoverBeat;

  const gutterHeight = Math.max(
    ...Object.values(positions).map((p) => p.top + p.span),
    0
  ) + 120;

  const bubblesByBeat = useMemo(() => {
    const m = new Map();
    for (const b of visibleBubbles) {
      if (!m.has(b.beatId)) m.set(b.beatId, []);
      m.get(b.beatId).push(b);
    }
    return m;
  }, [visibleBubbles]);

  return (
    <div className={`layout ${inlineMode ? "inline" : ""}`} ref={layoutRef}>
      <div className="article" ref={articleRef}>
        {doc.groups.map((g, gi) => {
          // 渐进解锁：未到 cutoff 的组直接不挂载（而非 display:none），
          // refs、ResizeObserver、slot 计算就自动只看得见的内容
          if (progress && !progress.visible[gi]) return null;
          const body = (
            <React.Fragment>
              {/* 双语原文里，同一段的中英文可能被别的内容隔开，于是一段分两处渲染。
                  读者需要知道的只是「这里接着上面那段」，不需要知道内部 id 和对齐机制。 */}
              {g.continuation && <div className="cont-mark">↳ 接上文同一段</div>}
              {g.blocks.map((bk, bi) => <Block key={bi} block={bk} imageDefs={doc.imageDefs} />)}
              {inlineMode && !g.continuation && (bubblesByBeat.get(g.ownerId) || []).map((b) => (
                b.draft ? (
                  <NoteEditor
                    key={b.id}
                    beatId={b.beatId}
                    chat={chat}
                    onClose={() => setDraftBeat(null)}
                    elRef={null}
                  />
                ) : b.note ? (
                  <NoteBubble
                    key={b.id}
                    note={b.note}
                    beatId={b.beatId}
                    active={false}
                    dim={false}
                    onEnter={() => {}}
                    onLeave={() => {}}
                    elRef={null}
                    chat={chat}
                  />
                ) : (
                  <Bubble key={b.id} bubble={b} style={null} active={false} dim={false}
                          onEnter={() => {}} onLeave={() => {}} elRef={null} chat={chat} />
                )
              ))}
            </React.Fragment>
          );

          // 写笔记入口是伪气泡，但不算「这段有批注」——否则每段都会被打上 has-bubbles
          const hasBubbles = !!(g.ownerId && (bubblesByBeat.get(g.ownerId) || []).some((b) => !b.write) && !g.continuation);

          return (
            <React.Fragment key={gi}>
              {g.pre.map((p, k) => (
                <Banner key={`pre${k}`} kind={p.kind} unit={p.unit} depth={p.depth} comment={(doc.comments.get(p.unit.id) || {}).commentary} chat={chat} />
              ))}
              {g.ownerId ? (
                <div
                  className={`beat ${hasBubbles ? "has-bubbles" : ""} ${activeBeat === g.ownerId ? "active" : ""}`}
                  data-beat-id={g.ownerId}
                  onMouseEnter={() => setHoverBeat(g.ownerId)}
                  onMouseLeave={() => setHoverBeat((cur) => (cur === g.ownerId ? null : cur))}
                  ref={(el) => {
                    // 只认主组（非 continuation）：挂载登记、卸载注销。卸载时 el 是
                    // null，靠 !g.continuation 保证 continuation 卸载不会误删主组的登记
                    if (g.continuation) return;
                    if (el) beatEls.current.set(g.ownerId, el);
                    else beatEls.current.delete(g.ownerId);
                  }}
                >
                  {/* 页边的 id 是留给校对的：拿它去 JSON 里定位这一段。对读者没意义，
                      所以只在鼠标停到这一段时才浮现（见 .beat-tag 的 opacity）。 */}
                  <div className="beat-tag" title="段落编号，用于和 JSON 对照">{g.ownerId.replace(/^c\d+-/, "")}</div>
                  {body}
                </div>
              ) : (
                <div className="loose">{body}</div>
              )}
              {g.post.map((p, k) => (
                <Banner key={`post${k}`} kind={p.kind} unit={p.unit} depth={p.depth} comment={(doc.comments.get(p.unit.id) || {}).commentary} chat={chat} />
              ))}
            </React.Fragment>
          );
        })}
        {/* 解锁按钮固定在可见正文末尾：新内容插在它上方，按钮在树中的位置
            不变，React 保留这个元素——不滚动、焦点不丢，可以连按 Enter 逐段读 */}
        {progress && !progress.done && (
          <div className="unlock-row">
            <button type="button" className="unlock-next" onClick={onUnlockNext}>
              解锁下一单元
              <span className="count">{progress.n}/{progress.total}</span>
            </button>
          </div>
        )}
      </div>

      {!inlineMode && (
        <div className="gutter" style={{ height: gutterHeight }} ref={gutterRef}>
          <svg className="connectors" width="100%" height={gutterHeight}>
            {visibleBubbles.map((b) => {
              // 写笔记入口平时不可见，不该有连接线牵着它
              if (b.write) return null;
              const p = positions[b.beatId];
              if (!p) return null;
              const y = p.anchor + 10;
              const on = (hoverBubble && hoverBubble.id === b.id) || activeBeat === b.beatId;
              return (
                <path
                  key={b.id}
                  // d 由上面的 rAF 逐帧改写，这里只给个初值
                  d={`M -40 ${y} C -18 ${y}, -18 ${p.top + 24}, 0 ${p.top + 24}`}
                  ref={(el) => {
                    if (el) pathEls.current.set(b.id, { el, beatId: b.beatId });
                    else pathEls.current.delete(b.id);
                  }}
                  fill="none"
                  stroke={on ? (b.voice === "mentor" ? "#b07d2b" : b.voice === "self" ? "#3d7a5f" : "#3f72a4") : "#ded6c8"}
                  strokeWidth={on ? 1.6 : 1}
                />
              );
            })}
          </svg>
          {[...bubblesByBeat.entries()].map(([beatId, members]) => {
            const p = positions[beatId];
            // 一个 beat 一个 slot；slot 平铺、互不重叠。组内气泡整体 sticky，
            // 所以两条气泡的相对间距永远是布局给的那个，不会各自钉到同一条线上。
            return (
              <div
                key={beatId}
                className="beat-slot"
                style={{
                  top: (p || { top: 0 }).top,
                  height: (p || { span: 0 }).span,
                  visibility: p ? "visible" : "hidden",
                }}
              >
                <div
                  className="beat-slot-group"
                  style={{ top: stickyTop }}
                  ref={(el) => {
                    const prev = groupEls.current.get(beatId);
                    if (el) {
                      groupEls.current.set(beatId, el);
                      groupRO.current.observe(el);
                    } else {
                      if (prev) groupRO.current.unobserve(prev);
                      groupEls.current.delete(beatId);
                    }
                  }}
                >
                  {members.map((b) => b.write ? (
                    // 隐形时也吃鼠标事件：悬停到入口所在区域即浮现（.beat-slot 本身
                    // pointer-events:none，事件只能落在入口自己身上）
                    <button
                      key={b.id}
                      type="button"
                      className={`write-note ${activeBeat === b.beatId ? "on" : ""}`}
                      title="给这一段写笔记"
                      onMouseEnter={() => setHoverBeat(b.beatId)}
                      onMouseLeave={() => setHoverBeat((cur) => (cur === b.beatId ? null : cur))}
                      onClick={() => setDraftBeat(b.beatId)}
                    >记</button>
                  ) : b.draft ? (
                    <NoteEditor
                      key={b.id}
                      beatId={b.beatId}
                      chat={chat}
                      onClose={() => setDraftBeat(null)}
                      elRef={(el) => { if (el) bubbleEls.current.set(b.id, el); else bubbleEls.current.delete(b.id); }}
                    />
                  ) : b.note ? (
                    <NoteBubble
                      key={b.id}
                      note={b.note}
                      beatId={b.beatId}
                      active={(!!hoverBubble && hoverBubble.id === b.id) || activeBeat === b.beatId}
                      dim={!!activeBeat && activeBeat !== b.beatId}
                      onEnter={() => setHoverBubble(b)}
                      onLeave={() => setHoverBubble((cur) => (cur && cur.id === b.id ? null : cur))}
                      elRef={(el) => { if (el) bubbleEls.current.set(b.id, el); else bubbleEls.current.delete(b.id); }}
                      chat={chat}
                    />
                  ) : (
                    <Bubble
                      key={b.id}
                      bubble={b}
                      style={null}
                      active={(!!hoverBubble && hoverBubble.id === b.id) || activeBeat === b.beatId}
                      dim={!!activeBeat && activeBeat !== b.beatId}
                      onEnter={() => setHoverBubble(b)}
                      onLeave={() => setHoverBubble((cur) => (cur && cur.id === b.id ? null : cur))}
                      elRef={(el) => { if (el) bubbleEls.current.set(b.id, el); else bubbleEls.current.delete(b.id); }}
                      chat={chat}
                    />
                  ))}
                </div>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

bandu.ui = { VOICE_LABEL, TYPE_LABEL, DEPTH_LABEL, RichText, Bubble, Banner, Reader, guardLine };

})(window.bandu);
