// ==UserScript== // @name B站直播间 快捷发言/+1 复读按钮 // @namespace https://scriptcat.org/ // @version 1.0.2 // @description B站直播间聊天 +1 复读按钮。支持文字复读、大表情/粉丝团表情复读、快捷发言发送/填入、样式配置、脚本开关、悬浮面板拖拽、上下智能展开。 // @author ScriptCat Agent // @match https://live.bilibili.com/* // @grant GM_addStyle // @run-at document-idle // @license GPL-3.0 // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = '__bili_live_plus_one_config__'; const defaultConfig = { enabled: true, text: '+1', fontSize: 12, textColor: '#ffffff', bgColor: '#00a1d6', borderColor: '#00a1d6', borderRadius: 30, maxButtons: 30, debug: true, repeatCount: 1, quickMessages: ['来了来了,晚上好', '哈哈哈哈', '主播加油', '支持一下'], floatPos: { right: 18, bottom: 120 } }; let config = loadConfig(); normalizeConfig(); let overlay = null; let scanTimer = null; let roomIdCache = null; const itemButtonMap = new Map(); GM_addStyle(` .sc-plus-one-overlay { position: fixed !important; inset: 0 !important; z-index: 2147483644 !important; pointer-events: none !important; } .sc-plus-one-btn { position: fixed !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; box-sizing: border-box !important; min-width: 34px !important; height: 24px !important; padding: 0 8px !important; margin: 0 !important; cursor: pointer !important; user-select: none !important; line-height: 1 !important; font-weight: 600 !important; z-index: 2147483645 !important; pointer-events: auto !important; outline: none !important; border-style: solid !important; box-shadow: 0 2px 8px rgba(0,0,0,.18) !important; } .sc-plus-one-btn:hover { opacity: .86 !important; transform: scale(1.04) !important; } .sc-plus-one-btn:active { transform: scale(.94) !important; } .sc-plus-one-btn:disabled { cursor: wait !important; opacity: .7 !important; } .sc-plus-one-float { position: fixed !important; right: 18px !important; bottom: 120px !important; width: 46px !important; height: 46px !important; border-radius: 50% !important; background: #00a1d6 !important; color: #fff !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 15px !important; font-weight: bold !important; cursor: move !important; user-select: none !important; z-index: 2147483647 !important; box-shadow: 0 4px 12px rgba(0,0,0,.22) !important; touch-action: none !important; } .sc-plus-one-panel { position: fixed !important; right: 18px !important; bottom: 176px !important; width: 330px !important; max-height: min(52vh, 430px) !important; overflow-y: auto !important; overflow-x: hidden !important; padding: 12px 12px 0 !important; background: #fff !important; color: #18191c !important; border: 1px solid #e3e5e7 !important; border-radius: 14px !important; box-shadow: 0 8px 30px rgba(0,0,0,.24) !important; z-index: 2147483646 !important; font-size: 13px !important; display: none; box-sizing: border-box !important; overscroll-behavior: contain !important; } .sc-plus-one-panel.sc-show { display: block !important; } .sc-plus-one-panel * { box-sizing: border-box !important; } .sc-plus-one-panel::-webkit-scrollbar { width: 8px; } .sc-plus-one-panel::-webkit-scrollbar-thumb { background: rgba(0,0,0,.18); border-radius: 8px; } .sc-plus-one-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } .sc-plus-one-panel-title { font-size: 15px; font-weight: bold; } .sc-plus-one-close { width: 36px; height: 36px; border: 0; border-radius: 8px; background: transparent; color: #61666d; cursor: pointer; font-size: 30px; line-height: 32px; padding: 0; } .sc-plus-one-close:hover { background: #f1f2f3; color: #18191c; } .sc-plus-one-row { display: flex; align-items: center; justify-content: space-between; margin: 7px 0; gap: 6px; } .sc-plus-one-row label { white-space: nowrap; font-size: 12px; color: #61666d; } .sc-plus-one-row input, .sc-plus-one-row select { flex: 1; min-width: 0; height: 30px; box-sizing: border-box; border: 1px solid #dcdfe6; border-radius: 8px; padding: 0 8px; outline: none; background: #fff; color: #18191c; font-size: 12px; } .sc-plus-one-row input:focus, .sc-plus-one-row select:focus, .sc-plus-one-msg-input:focus { border-color: #00aeec; box-shadow: 0 0 0 3px rgba(0,174,236,.12); } .sc-plus-one-switch { display: flex; align-items: center; gap: 5px; white-space: nowrap; font-size: 12px; color: #61666d; } .sc-plus-one-switch input { width: auto !important; height: auto !important; flex: none !important; } .sc-plus-one-small { max-width: 64px; } .sc-plus-one-btn2 { min-height: 30px; border: 1px solid #dcdfe6; border-radius: 8px; background: #fff; color: #18191c; padding: 4px 8px; cursor: pointer; font-size: 12px; white-space: nowrap; } .sc-plus-one-btn2:hover { background: #f6f7f8; } .sc-plus-one-btn-primary { border-color: #00aeec; background: #00aeec; color: #fff; } .sc-plus-one-btn-primary:hover { background: #009ad6; } .sc-plus-one-btn-danger { border-color: #fbc4c4; color: #d64545; background: #fff; } .sc-plus-one-color-line { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; margin: 8px 0; } .sc-plus-one-color-item { display: flex; align-items: center; justify-content: space-between; gap: 5px; height: 32px; padding: 0 7px; border: 1px solid #e3e5e7; border-radius: 8px; background: #f8f9fa; } .sc-plus-one-color-item span { font-size: 12px; color: #61666d; white-space: nowrap; } .sc-plus-one-color-item input[type="color"] { width: 28px; height: 24px; border: 0; border-radius: 6px; background: transparent; padding: 0; cursor: pointer; flex: none; } .sc-plus-one-hr { height: 1px; background: #e3e5e7; margin: 8px -12px; } .sc-plus-one-section-title { display: flex; align-items: center; justify-content: space-between; margin: 8px 0 7px; font-weight: 700; font-size: 13px; } .sc-plus-one-msg-list { display: flex; flex-direction: column; gap: 6px; padding-bottom: 6px; } .sc-plus-one-msg-item { display: grid; grid-template-columns: 1fr auto auto auto; align-items: center; gap: 5px; } .sc-plus-one-msg-input { width: 100%; min-width: 0; height: 30px; border: 1px solid #dcdfe6; border-radius: 8px; padding: 0 8px; outline: none; font-size: 12px; } .sc-plus-one-actions { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; gap: 6px; margin: 8px -12px 0; padding: 8px 12px; background: #fff; border-top: 1px solid #e3e5e7; box-shadow: 0 -6px 16px rgba(0,0,0,.06); } .sc-plus-one-toast { position: fixed !important; left: 50% !important; bottom: 90px !important; transform: translateX(-50%) !important; background: rgba(0,0,0,.78) !important; color: #fff !important; padding: 8px 12px !important; border-radius: 6px !important; font-size: 13px !important; z-index: 2147483647 !important; max-width: 620px !important; text-align: center !important; pointer-events: none !important; line-height: 1.5 !important; } `); function normalizeConfig() { config.enabled = config.enabled !== false; config.text = String(config.text || '+1'); config.fontSize = clamp(Number(config.fontSize), 10, 30); config.textColor = String(config.textColor || '#ffffff'); config.bgColor = String(config.bgColor || '#00a1d6'); config.borderColor = String(config.borderColor || '#00a1d6'); config.borderRadius = clamp(Number(config.borderRadius), 0, 30); config.maxButtons = clamp(Number(config.maxButtons), 1, 80); config.debug = config.debug === true; config.repeatCount = clamp(Number(config.repeatCount), 1, 20); if (!Array.isArray(config.quickMessages)) { config.quickMessages = defaultConfig.quickMessages.slice(); } config.quickMessages = config.quickMessages .map(item => String(item || '').trim()) .filter(Boolean) .slice(0, 30); if (!config.floatPos || typeof config.floatPos !== 'object') { config.floatPos = { ...defaultConfig.floatPos }; } config.floatPos.right = Number.isFinite(Number(config.floatPos.right)) ? Number(config.floatPos.right) : defaultConfig.floatPos.right; config.floatPos.bottom = Number.isFinite(Number(config.floatPos.bottom)) ? Number(config.floatPos.bottom) : defaultConfig.floatPos.bottom; } function loadConfig() { try { return Object.assign({}, defaultConfig, JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')); } catch { return { ...defaultConfig }; } } function saveConfig() { normalizeConfig(); localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); updateAllButtonStyles(); updateFloatStyle(); } function ensureOverlay() { if (overlay && document.body.contains(overlay)) return overlay; overlay = document.createElement('div'); overlay.className = 'sc-plus-one-overlay'; document.body.appendChild(overlay); return overlay; } function applyButtonStyle(btn) { btn.textContent = config.text || '+1'; btn.style.fontSize = `${Number(config.fontSize) || 12}px`; btn.style.color = config.textColor || '#ffffff'; btn.style.background = config.bgColor || '#00a1d6'; btn.style.border = `1px solid ${config.borderColor || '#00a1d6'}`; btn.style.borderRadius = `${Number(config.borderRadius) || 30}px`; } function updateAllButtonStyles() { document.querySelectorAll('.sc-plus-one-btn').forEach(applyButtonStyle); } function updateFloatStyle() { const float = document.querySelector('.sc-plus-one-float'); if (!float) return; float.textContent = '设置'; float.style.setProperty('right', `${config.floatPos.right}px`, 'important'); float.style.setProperty('bottom', `${config.floatPos.bottom}px`, 'important'); float.style.setProperty('display', 'flex', 'important'); } function stopEvent(e) { e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') { e.stopImmediatePropagation(); } } function debugToast(message) { if (config.debug) toast(message); } function installGlobalPlusOneDispatcher() { const triggerEvents = ['pointerdown', 'mousedown', 'touchstart', 'click']; triggerEvents.forEach(eventName => { document.addEventListener(eventName, async (e) => { const btn = findPlusOneButtonFromEvent(e); if (!btn) return; stopEvent(e); if (!config.enabled) { toast('脚本已关闭'); return; } const now = Date.now(); if (now - (btn.__scLastTrigger || 0) < 800) return; btn.__scLastTrigger = now; await handlePlusOneClick(btn, btn.__scChatItem); }, true); }); ['pointerup', 'mouseup', 'dblclick', 'contextmenu'].forEach(eventName => { document.addEventListener(eventName, (e) => { const btn = findPlusOneButtonFromEvent(e); if (btn) stopEvent(e); }, true); }); } function findPlusOneButtonFromEvent(e) { const target = e.target; if (target && target.closest) { const btn = target.closest('.sc-plus-one-btn'); if (btn) return btn; } const point = getEventPoint(e); if (!point) return null; const el = document.elementFromPoint(point.x, point.y); if (el && el.closest) return el.closest('.sc-plus-one-btn'); return null; } function getEventPoint(e) { if (e.touches && e.touches[0]) { return { x: e.touches[0].clientX, y: e.touches[0].clientY }; } if (e.changedTouches && e.changedTouches[0]) { return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }; } if (typeof e.clientX === 'number' && typeof e.clientY === 'number') { return { x: e.clientX, y: e.clientY }; } return null; } function createPlusOneButton(chatItem) { if (!chatItem || !config.enabled) return null; const existed = itemButtonMap.get(chatItem); if (existed) return existed; const payload = extractChatPayload(chatItem); if (!payload.text && payload.emojis.length === 0) return null; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'sc-plus-one-btn'; btn.title = '复读这条发言'; btn.setAttribute('aria-label', '复读这条发言'); btn.setAttribute('data-sc-plus-one', '1'); btn.__scChatItem = chatItem; btn.__scLastTrigger = 0; applyButtonStyle(btn); ensureOverlay().appendChild(btn); itemButtonMap.set(chatItem, btn); updateButtonPosition(chatItem, btn); return btn; } async function handlePlusOneClick(btn, chatItem) { if (!btn || !chatItem) { toast('按钮没有绑定到聊天消息'); return; } const payload = extractChatPayload(chatItem); if (!payload.text && payload.emojis.length === 0) { toast('没有识别到可发送内容'); return; } btn.disabled = true; btn.textContent = '...'; try { const result = await sendPayload(payload); if (!result.ok) { toast(result.message || '发送失败'); } else { debugToast(result.message || '已发送'); } } catch (err) { console.error('[+1] send error:', err); toast(`发送报错:${err && err.message ? err.message : String(err)}`); } finally { btn.disabled = false; applyButtonStyle(btn); } } function extractChatPayload(chatItem) { const payload = { text: '', emojis: [] }; if (!chatItem) return payload; const datasetEmoji = extractDatasetEmoji(chatItem); if (datasetEmoji) { payload.text = ''; payload.emojis = [datasetEmoji]; if (config.debug) { console.log('[+1] 识别为大表情:', datasetEmoji, chatItem); } return payload; } const cloned = chatItem.cloneNode(true); cloned.querySelectorAll(` .fans-medal-item, .medal, .wealth-medal, .guard-icon, .user-level-icon, .uname, .username, .name, .time, .avatar, .face, svg, button, img `).forEach(el => el.remove()); const contentSelectors = [ '.danmaku-content', '.chat-item-text', '.content', '.text', '.msg', '.danmaku-item-right', '.chat-item-right' ]; let contentEl = null; for (const selector of contentSelectors) { const el = cloned.querySelector(selector); if (el) { contentEl = el; break; } } if (!contentEl) contentEl = cloned; payload.text = normalizeText(contentEl.innerText || contentEl.textContent); payload.emojis = []; if (config.debug) { console.log('[+1] 识别为文字:', payload.text, chatItem); } return payload; } function extractDatasetEmoji(chatItem) { if (!chatItem || !chatItem.dataset) return null; const dataset = chatItem.dataset; const fileId = String(dataset.fileId || '').trim(); const image = normalizeImgUrl(dataset.image || ''); const danmaku = cleanEmojiName(dataset.danmaku || ''); if (!fileId || !image) return null; const className = String(chatItem.className || ''); const looksLikeEmoji = /chat-emoticon|bulge-emoticon|emoticon/i.test(className) || Boolean(dataset.danmaku); if (!looksLikeEmoji && config.debug) { console.warn('[+1] 有 fileId/image,但类名不像表情,仍按表情处理:', { className, dataset: Object.assign({}, dataset) }); } const img = chatItem.querySelector('img'); const rect = img ? img.getBoundingClientRect() : null; return { name: danmaku, fileId, src: image, rawSrc: image, width: rect && rect.width ? Math.round(rect.width) : 40, height: rect && rect.height ? Math.round(rect.height) : 40, bulge: /bulge-emoticon|chat-emoticon|emoticon/i.test(className), chatDataset: Object.assign({}, dataset) }; } async function sendPayload(payload) { const emojis = Array.isArray(payload?.emojis) ? payload.emojis : []; const text = normalizeText(payload?.text || ''); if (emojis.length > 0) { return sendEmoticonByApi(emojis[0]); } if (text) { return sendTextByApi(text); } return { ok: false, message: '没有可发送内容' }; } async function sendTextByApi(text) { return sendLiveMsg({ msg: text, dmType: 0 }); } async function sendEmoticonByApi(emoji) { if (!emoji || !emoji.fileId || !emoji.src) { return { ok: false, message: '缺少表情 fileId 或 image,无法发送表情' }; } const emoticonOptions = { bulge_display: emoji.bulge ? 1 : 0, emoticon_unique: emoji.fileId, height: Number(emoji.height) || 40, width: Number(emoji.width) || 40, in_player_area: 1, url: normalizeImgUrl(emoji.src) }; if (config.debug) { console.log('[+1] 发送大表情参数:', { msg: emoji.fileId, dm_type: 1, emoticonOptions }); } return sendLiveMsg({ msg: emoji.fileId, dmType: 1, extra: { emoticonOptions: JSON.stringify(emoticonOptions) }, debugLabel: 'emoticonOptions + msg=fileId' }); } async function sendQuickMessage(text) { if (!config.enabled) { toast('脚本已关闭'); return; } const cleanText = normalizeText(text); if (!cleanText) { toast('快捷发言不能为空'); return; } const repeat = clamp(Number(config.repeatCount), 1, 20); let success = 0; let lastMessage = ''; for (let i = 0; i < repeat; i++) { const result = await sendTextByApi(cleanText); if (result.ok) { success += 1; } else { lastMessage = result.message || '发送失败'; break; } if (i < repeat - 1) { await sleep(450); } } if (success > 0) { toast(`已发送 ${success} 次`); } else { toast(lastMessage || '发送失败'); } } async function fillQuickMessage(text) { if (!config.enabled) { toast('脚本已关闭'); return; } const cleanText = normalizeText(text); if (!cleanText) { toast('快捷发言不能为空'); return; } const input = findDanmakuInput(); if (!input) { toast('没有找到弹幕输入框'); return; } setNativeValue(input, cleanText); toast('已填入输入框'); } function findDanmakuInput() { const selectors = [ 'textarea.chat-input', '.chat-input textarea', 'textarea[placeholder*="弹幕"]', 'textarea[placeholder*="发个弹幕"]', 'textarea[placeholder*="说点什么"]', 'input[placeholder*="弹幕"]', 'input[placeholder*="说点什么"]', '[contenteditable="true"][placeholder*="弹幕"]', '[contenteditable="true"]' ]; for (const sel of selectors) { const el = document.querySelector(sel); if (isUsableInput(el)) return el; } return [...document.querySelectorAll('textarea,input,[contenteditable="true"]')].find(isUsableInput) || null; } function isUsableInput(el) { if (!el) return false; if (!isVisible(el)) return false; if (el.disabled || el.getAttribute('disabled') !== null) return false; const tag = el.tagName.toLowerCase(); const editable = el.getAttribute('contenteditable') === 'true'; return tag === 'textarea' || tag === 'input' || editable; } function setNativeValue(el, value) { const tag = el.tagName.toLowerCase(); const editable = el.getAttribute('contenteditable') === 'true'; el.focus(); if (editable) { el.innerText = value; el.textContent = value; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value })); el.dispatchEvent(new Event('change', { bubbles: true })); return; } if (tag === 'textarea' || tag === 'input') { const proto = tag === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (setter) { setter.call(el, value); } else { el.value = value; } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } } async function sendLiveMsg(options) { const csrf = getCookie('bili_jct'); if (!csrf) { return { ok: false, message: '未找到 bili_jct,可能未登录或 Cookie 不可读' }; } const roomId = await getRoomId(); if (!roomId) { return { ok: false, message: '未识别到直播间 room_id' }; } const body = new URLSearchParams(); body.set('bubble', '0'); body.set('msg', options.msg || ''); body.set('color', '16777215'); body.set('mode', '1'); body.set('fontsize', '25'); body.set('rnd', String(Math.floor(Date.now() / 1000))); body.set('roomid', String(roomId)); body.set('csrf', csrf); body.set('csrf_token', csrf); if (options.dmType !== undefined && options.dmType !== null) { body.set('dm_type', String(options.dmType)); } if (options.extra && typeof options.extra === 'object') { for (const [key, value] of Object.entries(options.extra)) { if (value !== undefined && value !== null && value !== '') { body.set(key, String(value)); } } } const res = await fetch('https://api.live.bilibili.com/msg/send', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString() }); let data = null; try { data = await res.json(); } catch { return { ok: false, message: `接口响应不是 JSON:HTTP ${res.status}` }; } if (data && data.code === 0) { return { ok: true, message: options.dmType === 1 ? '已通过接口发送表情' : '已通过接口发送文字' }; } console.warn('[+1] B站接口返回:', { label: options.debugLabel || '', request: Object.fromEntries(body.entries()), response: data }); return { ok: false, message: `${options.debugLabel || '接口发送失败'}:${data && data.message ? data.message : JSON.stringify(data)}` }; } async function getRoomId() { if (roomIdCache) return roomIdCache; const fromWindow = window.__NEPTUNE_IS_MY_WAIFU__ && window.__NEPTUNE_IS_MY_WAIFU__.roomInitRes && window.__NEPTUNE_IS_MY_WAIFU__.roomInitRes.data && window.__NEPTUNE_IS_MY_WAIFU__.roomInitRes.data.room_id || window.__room_id__ || window.room_id; if (fromWindow) { roomIdCache = fromWindow; return roomIdCache; } const match = location.pathname.match(/\/(\d+)/); const shortId = match && match[1]; if (!shortId) return null; try { const res = await fetch(`https://api.live.bilibili.com/room/v1/Room/room_init?id=${encodeURIComponent(shortId)}`, { credentials: 'include' }); const data = await res.json(); if (data && data.code === 0 && data.data && data.data.room_id) { roomIdCache = data.data.room_id; return roomIdCache; } } catch {} return shortId; } function getCookie(name) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : ''; } function updateButtonPosition(chatItem, btn) { if (!chatItem || !btn || !document.body.contains(chatItem)) { removeButton(chatItem); return; } const target = findPositionTarget(chatItem); const panel = findChatPanel(chatItem); const rect = target.getBoundingClientRect(); const panelRect = panel ? panel.getBoundingClientRect() : { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }; const visible = rect.width > 0 && rect.height > 0 && rect.bottom > panelRect.top + 2 && rect.top < panelRect.bottom - 2 && rect.right > panelRect.left && rect.left < panelRect.right; if (!visible || !config.enabled) { btn.style.display = 'none'; return; } btn.style.display = 'inline-flex'; const btnWidth = btn.offsetWidth || 34; const btnHeight = btn.offsetHeight || 24; const gap = 10; let left = rect.right + gap; let top = rect.top + Math.max(0, (rect.height - btnHeight) / 2); left = Math.min(left, panelRect.right - btnWidth - 4); left = Math.max(left, panelRect.left + 4); top = Math.max(top, panelRect.top + 4); top = Math.min(top, panelRect.bottom - btnHeight - 4); btn.style.left = `${Math.round(left)}px`; btn.style.top = `${Math.round(top)}px`; } function removeButton(chatItem) { const btn = itemButtonMap.get(chatItem); if (btn) btn.remove(); itemButtonMap.delete(chatItem); } function clearPlusButtons() { for (const item of Array.from(itemButtonMap.keys())) { removeButton(item); } } function findPositionTarget(chatItem) { const selectors = [ '.danmaku-item-right', '.chat-item-right', '.danmaku-content', '.chat-item-text', '.content', '.text', '.msg' ]; for (const selector of selectors) { const el = chatItem.querySelector(selector); if (el && isVisible(el)) return el; } return chatItem; } function findChatPanel(chatItem) { let el = chatItem; for (let i = 0; i < 8 && el && el !== document.body; i++) { const style = getComputedStyle(el); const rect = el.getBoundingClientRect(); const canScroll = el.scrollHeight > el.clientHeight + 20; if ( rect.height > 120 && canScroll && /auto|scroll|overlay/i.test(style.overflowY) ) { return el; } el = el.parentElement; } return null; } function scanChatItems() { ensureOverlay(); if (!config.enabled) { clearPlusButtons(); return; } const selectors = [ '.danmaku-item', '.chat-item', '.msg-item', '.chat-history-list-item', '[data-danmaku-id]' ]; const rawItems = []; selectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { const text = normalizeText(el.innerText); const hasImg = !!el.querySelector('img'); const hasDatasetEmoji = !!( el.dataset && el.dataset.fileId && el.dataset.image ); if ((text || hasImg || hasDatasetEmoji) && text.length <= 500) { rawItems.push(el); } }); }); const uniqueItems = rawItems.filter(el => { return !rawItems.some(other => other !== el && other.contains(el)); }); const visibleItems = uniqueItems.filter(isChatItemVisibleInPanel); const maxButtons = Math.max(1, Number(config.maxButtons) || 30); const keepItems = visibleItems.slice(-maxButtons); const keepSet = new Set(keepItems); for (const item of Array.from(itemButtonMap.keys())) { if (!document.body.contains(item) || !keepSet.has(item)) { removeButton(item); } } keepItems.forEach(item => { try { const btn = createPlusOneButton(item); if (btn) { btn.__scChatItem = item; updateButtonPosition(item, btn); } } catch (err) { console.warn('[+1] 创建按钮失败:', err, item); } }); } function isChatItemVisibleInPanel(item) { if (!item || !document.body.contains(item)) return false; const rect = item.getBoundingClientRect(); const panel = findChatPanel(item); const panelRect = panel ? panel.getBoundingClientRect() : { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }; return rect.height > 0 && rect.width > 0 && rect.bottom > panelRect.top + 2 && rect.top < panelRect.bottom - 2 && rect.right > panelRect.left && rect.left < panelRect.right; } function createFloatPanel() { if (document.querySelector('.sc-plus-one-float')) return; const float = document.createElement('div'); float.className = 'sc-plus-one-float'; float.textContent = '设置'; float.title = '打开 +1 设置面板'; const panel = document.createElement('div'); panel.className = 'sc-plus-one-panel'; document.body.appendChild(float); document.body.appendChild(panel); updateFloatStyle(); let dragging = false; let moved = false; let startX = 0; let startY = 0; let startRight = 0; let startBottom = 0; float.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; dragging = true; moved = false; startX = e.clientX; startY = e.clientY; startRight = Number(config.floatPos.right) || defaultConfig.floatPos.right; startBottom = Number(config.floatPos.bottom) || defaultConfig.floatPos.bottom; float.setPointerCapture?.(e.pointerId); e.preventDefault(); e.stopPropagation(); }); float.addEventListener('pointermove', (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { moved = true; } const nextRight = clamp(startRight - dx, 8, Math.max(8, window.innerWidth - 54)); const nextBottom = clamp(startBottom - dy, 8, Math.max(8, window.innerHeight - 54)); config.floatPos.right = nextRight; config.floatPos.bottom = nextBottom; updateFloatStyle(); updatePanelPosition(); }); float.addEventListener('pointerup', (e) => { if (!dragging) return; dragging = false; float.releasePointerCapture?.(e.pointerId); config.floatPos.right = clamp(Number(config.floatPos.right), 8, Math.max(8, window.innerWidth - 54)); config.floatPos.bottom = clamp(Number(config.floatPos.bottom), 8, Math.max(8, window.innerHeight - 54)); saveConfig(); updateFloatStyle(); updatePanelPosition(); }); float.addEventListener('pointercancel', (e) => { dragging = false; float.releasePointerCapture?.(e.pointerId); }); float.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (moved) { moved = false; return; } panel.classList.add('sc-show'); renderPanel(panel); updatePanelPosition(); }); window.addEventListener('resize', () => { config.floatPos.right = clamp(Number(config.floatPos.right), 8, Math.max(8, window.innerWidth - 54)); config.floatPos.bottom = clamp(Number(config.floatPos.bottom), 8, Math.max(8, window.innerHeight - 54)); saveConfig(); updateFloatStyle(); updatePanelPosition(); }); } function updatePanelPosition() { const float = document.querySelector('.sc-plus-one-float'); const panel = document.querySelector('.sc-plus-one-panel'); if (!float || !panel) return; const rect = float.getBoundingClientRect(); const panelWidth = 330; const gap = 10; const margin = 8; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let right = viewportWidth - rect.right; right = clamp( right, margin, Math.max(margin, viewportWidth - panelWidth - margin) ); panel.style.setProperty('right', `${Math.round(right)}px`, 'important'); const shouldOpenDown = rect.top < viewportHeight / 2; if (shouldOpenDown) { const top = rect.bottom + gap; const availableHeight = viewportHeight - top - margin; const maxHeight = Math.max( 220, Math.min(availableHeight, viewportHeight * 0.52, 430) ); panel.style.setProperty('top', `${Math.round(top)}px`, 'important'); panel.style.setProperty('bottom', 'auto', 'important'); panel.style.setProperty('max-height', `${Math.round(maxHeight)}px`, 'important'); } else { const bottom = viewportHeight - rect.top + gap; const availableHeight = rect.top - gap - margin; const maxHeight = Math.max( 220, Math.min(availableHeight, viewportHeight * 0.52, 430) ); panel.style.setProperty('top', 'auto', 'important'); panel.style.setProperty('bottom', `${Math.round(bottom)}px`, 'important'); panel.style.setProperty('max-height', `${Math.round(maxHeight)}px`, 'important'); } } function renderPanel(panel) { panel.innerHTML = `