/* ============================================================ ROOY · "问问 ROOY" 长按气泡 ─────────────────────────────────────────────────────────── 需求:05 复盘 / 06 课程里,用户长按任意 ROOY 内容卡片(雷达 / 能力卡 / 路径卡 / 节点卡)→ 弹出底部会话气泡快速向 ROOY 提问。 交互: · trigger 长按 ≥450ms(iOS HIG contextMenu 标准时长) · feedback 长按过程中元素 scale 0.985 + 0.15s 缓动 · open backdrop dim + blur,气泡浮在 TabBar 上方 12px · dismiss 点击 backdrop / 输入空时点发送按钮无效 · onSend 由调用方决定(暂留 console.log,未来接 'rooy' with prefill = { topic, text }) 形态:底部统一定位 / 不 anchor 到元素。雷达 326×230、能力行 全宽、路径卡半屏、节点卡变高 —— 尺寸差异太大,anchored popover 在 viewport 边界会翻转跳变。元素 scale 反馈 + 底部气泡足够建立 "我捏住的是这块" 的空间联想。这套 modal 跟 fault-alerts 同语言: 屏内 accent budget 不计。ROOY 绿出现在 context chip + ROOY dot + 发送按钮 —— 全是 ROOY "在场"语义. 依赖 shared.jsx (R, T, Shadow, B 不需)。无 CSS 文件,keyframes 首次挂载时 useEffect 注入 。 ============================================================ */ /* useLongPress —— touch + mouse 双源长按检测。 返回 { pressed, handlers }:pressed 给元素做 scale 反馈, handlers spread 到目标 div 上。onClickCapture 拦截长按触发后 的 synthetic click,避免长按完同时触发卡片自己的展开/选中。 */ function useLongPress(handler, { ms = 450 } = {}) { const [pressed, setPressed] = React.useState(false); const timerRef = React.useRef(null); const firedRef = React.useRef(false); const start = () => { firedRef.current = false; setPressed(true); timerRef.current = setTimeout(() => { firedRef.current = true; setPressed(false); handler(); }, ms); }; const cancel = () => { setPressed(false); if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }; return { pressed, handlers: { onTouchStart: start, onTouchEnd: cancel, onTouchMove: cancel, onTouchCancel: cancel, onMouseDown: start, onMouseUp: cancel, onMouseLeave: cancel, onContextMenu: (e) => e.preventDefault(), onClickCapture: (e) => { if (firedRef.current) { e.stopPropagation(); e.preventDefault(); firedRef.current = false; } }, }, }; } /* 通用 suggested prompts —— ROOY 教练语境下用户最常发起的 3 个 方向:动作 / 原理 / 案例。短、问题导向,跟 ROOY 自己的反馈语气 对称(参考 review.jsx 的"右弯入弯踩脚趾·3 趟连续"那种克制)。 不按 context 分组:跨"雷达 / 能力 / 路径 / 节点"都成立,省去 一份 lookup 表的代码 / 设计维护成本。 */ const ASK_ROOY_PROMPTS = ['怎么练?', '为什么是这个?', '举个例子']; /* AskRooyBubble —— 底部会话气泡 modal。 props: · context string 被长按对象的标签("立刃" / "节奏" / "刃滑路径") · onClose () 用户点 backdrop 或发送完后调用 · onSend (text, context) 发送按钮回调;text trim 后 ≥1 字才能发. */ function AskRooyBubble({ context, onClose, onSend, embedded }) { const [text, setText] = React.useState(''); React.useEffect(() => { if (document.getElementById('rooy-bubble-anim')) return; const s = document.createElement('style'); s.id = 'rooy-bubble-anim'; s.textContent = ` @keyframes rooy-bubble-bd-in { from { opacity: 0; } to { opacity: 1; } } @keyframes rooy-bubble-in { from { transform: translateY(14px) scale(0.96); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } } `; document.head.appendChild(s); }, []); const send = () => { const v = text.trim(); if (!v) return; onSend && onSend(v, context); onClose && onClose(); }; return (
e.stopPropagation()} style={{ width: 'calc(100% - 32px)', maxWidth: 360, marginBottom: 92, background: R.card, borderRadius: 22, boxShadow: Shadow.alert, padding: '16px 16px 14px', animation: 'rooy-bubble-in 240ms cubic-bezier(0.2,0,0,1) both', }} > {/* 单行标题 —— 之前 context chip + ROOY 句子两行合并为一行: 绿 dot(ROOY 在场)+ "问问 ROOY:关于「{context}」". 句式 自带 context anchor 不再需要单独 chip 强调. */}

问问 ROOY:关于「{context}」

{/* suggested prompts —— 3 个 chip 横排,wrap 兜底窄屏。点击 直接发送(不预填到 input),快路径。chip 视觉跟 iOS keyboard quick replies 同语言:systemFill 圆角 pill + ink2. */}
{ASK_ROOY_PROMPTS.map((p) => ( ))}
{/* 输入栏 —— iOS systemFill pill,右侧 ROOY 绿圆形 ↑ 发送 */}
setText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') send(); }} placeholder="说点什么…" autoFocus style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', ...T.body, color: R.ink, padding: '6px 0', fontFamily: 'inherit', }} />
); } /* AskRooyBubbleDemo —— phone artboard 用的演示 wrapper。 AskRooyBubble 是浮层(live mode position: fixed 贴 viewport), embedded=true 让它换成 absolute 贴 phone 内 viewport,并把 onClose / onSend 接成空函数让 demo 不会触发真实跳转。 context 用 "立刃" 跟开发文档里的最高频示例对齐。 */ function AskRooyBubbleDemo() { return (
{}} onSend={() => {}} />
); } Object.assign(window, { useLongPress, AskRooyBubble, AskRooyBubbleDemo });