// ==UserScript== // @name 抖音私信聊天分屏 // @namespace https://github.com/douyin-chat-splitview // @version 1.2.0 // @description 把抖音 Web 的私信聊天弹窗变成右侧固定分屏,支持展开/收起、拖动改宽、状态记忆 // @author Ccc // @match https://www.douyin.com/* // @match https://*.douyin.com/* // @run-at document-idle // @grant none // @noframes // ==/UserScript== (function () { 'use strict'; const SCRIPT_ID = 'douyin-chat-splitview'; const DEFAULT_RATIO = 0.5; // 默认 50% 分屏 const MIN_RATIO = 0.28; const MAX_RATIO = 0.72; const STORE_KEY = 'dy-chat-splitview-v2'; const DEBUG = false; const log = (...a) => DEBUG && console.log('[抖音聊天分屏]', ...a); if (window.__douyinChatSplitView) { console.warn('[抖音聊天分屏] 脚本已加载'); return; } window.__douyinChatSplitView = true; const state = Object.assign({ ratio: DEFAULT_RATIO, collapsed: false }, readStore()); function readStore() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch { return {}; } } function saveStore() { try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch { /* ignore */ } } function clampWidth(w) { const min = Math.round(window.innerWidth * MIN_RATIO); const max = Math.round(window.innerWidth * MAX_RATIO); return Math.round(Math.min(max, Math.max(min, w))); } function widthFromRatio(ratio = state.ratio) { return clampWidth(window.innerWidth * (ratio || DEFAULT_RATIO)); } const style = document.createElement('style'); style.id = SCRIPT_ID; style.textContent = ` :root { --dy-chat-w: ${widthFromRatio()}px; } .dy-chat-docked { position: fixed !important; inset: 0 0 0 auto !important; width: var(--dy-chat-w) !important; height: 100vh !important; max-height: 100vh !important; min-height: 100vh !important; margin: 0 !important; border-radius: 0 !important; z-index: 999998 !important; background: #fff !important; box-shadow: -2px 0 16px rgba(0,0,0,0.12) !important; transition: transform .28s ease !important; transform: translateX(0) !important; display: flex !important; flex-direction: column !important; overflow: hidden !important; box-sizing: border-box !important; } .dy-chat-docked.dy-collapsed { transform: translateX(100%) !important; } /* 外层 + 主内容区撑满 */ .dy-chat-docked > div, .dy-chat-docked .dy-split-row { flex: 1 1 auto !important; width: 100% !important; height: 100% !important; max-height: 100vh !important; min-height: 0 !important; box-sizing: border-box !important; } .dy-chat-docked .dy-split-row { display: flex !important; flex-direction: row !important; align-items: stretch !important; overflow: hidden !important; } .dy-chat-docked .dy-split-col { height: 100% !important; min-height: 0 !important; overflow: hidden !important; box-sizing: border-box !important; } /* 双栏:左列表 + 右会话 */ .dy-chat-docked:not(.dy-list-only) .dy-split-col:first-child { flex: 0 0 36% !important; max-width: 40% !important; min-width: 220px !important; } .dy-chat-docked:not(.dy-list-only) .dy-split-col:last-child { flex: 1 1 auto !important; min-width: 0 !important; width: auto !important; } /* 仅列表:隐藏空白的会话列,列表占满 */ .dy-chat-docked.dy-list-only .dy-split-col:last-child { display: none !important; } .dy-chat-docked.dy-list-only .dy-split-col:first-child { flex: 1 1 100% !important; width: 100% !important; max-width: 100% !important; } /* 会话区内层再撑满 */ .dy-chat-docked .dy-split-col > div { height: 100% !important; min-height: 0 !important; } html.dy-split-active body { margin-right: var(--dy-chat-w) !important; transition: margin-right .28s ease !important; } html.dy-split-active.dy-split-collapsed body { margin-right: 0 !important; } .dy-chat-resizer { position: fixed !important; top: 0 !important; bottom: 0 !important; right: var(--dy-chat-w) !important; width: 6px !important; cursor: col-resize !important; z-index: 999999 !important; background: transparent !important; transition: background .15s ease !important; } .dy-chat-resizer:hover, .dy-chat-resizer.dragging { background: rgba(254, 44, 85, .5) !important; } html.dy-split-collapsed .dy-chat-resizer { display: none !important; } .dy-chat-toggle { position: fixed !important; top: 50% !important; right: var(--dy-chat-w) !important; transform: translateY(-50%) !important; width: 22px !important; height: 64px !important; background: #161823 !important; color: #fff !important; border: none !important; border-radius: 10px 0 0 10px !important; cursor: pointer !important; z-index: 1000000 !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 13px !important; box-shadow: -2px 0 8px rgba(0,0,0,.18) !important; transition: right .28s ease, background .15s ease, width .15s ease !important; } .dy-chat-toggle:hover { background: #2b2e3d !important; width: 26px !important; } html.dy-split-collapsed .dy-chat-toggle { right: 0 !important; } html:not(.dy-split-active) .dy-chat-toggle, html:not(.dy-split-active) .dy-chat-resizer { display: none !important; } `; (document.head || document.documentElement).appendChild(style); const root = document.documentElement; const toggleBtn = document.createElement('button'); toggleBtn.className = 'dy-chat-toggle'; toggleBtn.type = 'button'; const resizer = document.createElement('div'); resizer.className = 'dy-chat-resizer'; document.body.appendChild(toggleBtn); document.body.appendChild(resizer); let chatPanel = null; let suppressUntil = 0; let panelWatcher = null; function applyWidth(w) { const width = clampWidth(w); state.ratio = width / window.innerWidth; root.style.setProperty('--dy-chat-w', width + 'px'); saveStore(); } function applyCollapsed(collapsed) { state.collapsed = collapsed; root.classList.toggle('dy-split-collapsed', collapsed); if (chatPanel) chatPanel.classList.toggle('dy-collapsed', collapsed); toggleBtn.textContent = collapsed ? '◀' : '▶'; toggleBtn.title = collapsed ? '展开聊天面板 (Ctrl+Shift+C)' : '收起聊天面板 (Ctrl+Shift+C)'; saveStore(); } function toggle() { applyCollapsed(!state.collapsed); } toggleBtn.addEventListener('click', toggle); document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.key === 'c')) { e.preventDefault(); toggle(); } if (e.key === 'Escape' && chatPanel) suppressDock(4000); }); window.addEventListener('resize', () => { if (!chatPanel) return; applyWidth(window.innerWidth * state.ratio); refreshPanelLayout(); }); function suppressDock(ms = 3000) { suppressUntil = Date.now() + ms; } let dragging = false; resizer.addEventListener('mousedown', (e) => { dragging = true; resizer.classList.add('dragging'); document.body.style.userSelect = 'none'; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!dragging) return; applyWidth(window.innerWidth - e.clientX); }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; resizer.classList.remove('dragging'); document.body.style.userSelect = ''; }); function isVisible(el) { if (!el || !document.contains(el)) return false; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false; const r = el.getBoundingClientRect(); return r.width > 40 && r.height > 40; } function hasSendInput(panel) { const inputs = panel.querySelectorAll('textarea, input, [contenteditable="true"]'); for (const el of inputs) { const ph = (el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || '').trim(); if ((ph.includes('发送消息') || ph.includes('发消息')) && isVisible(el)) return true; } return false; } function isFloatingPanel(el) { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); const positioned = cs.position === 'fixed' || cs.position === 'absolute'; const okWidth = r.width >= 460 && r.width <= window.innerWidth * 0.92; const okHeight = r.height >= 320; return positioned && okWidth && okHeight; } function climbToPanel(start) { let el = start; for (let depth = 0; depth < 14 && el && el !== document.body; depth++) { if (isFloatingPanel(el)) return el; el = el.parentElement; } return null; } function leafNodesMatching(matchFn) { const out = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null); let node; while ((node = walker.nextNode())) { if (node.childElementCount === 0 && matchFn(node)) out.push(node); } return out; } function findBySendInput() { const inputs = document.querySelectorAll('textarea, input, [contenteditable="true"]'); for (let i = inputs.length - 1; i >= 0; i--) { const el = inputs[i]; const ph = (el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || '').trim(); if (ph.includes('发送消息') || ph.includes('发消息')) { const panel = climbToPanel(el); if (panel) return panel; } } return null; } function findByPrivateLabel() { const labels = leafNodesMatching((n) => n.textContent.trim() === '私信'); for (let i = labels.length - 1; i >= 0; i--) { const panel = climbToPanel(labels[i]); if (panel) return panel; } return null; } function findByRightFloatingPanel() { const nodes = document.body.querySelectorAll('div'); let best = null; let bestScore = 0; for (let i = nodes.length - 1; i >= 0; i--) { const el = nodes[i]; if (!isFloatingPanel(el)) continue; const r = el.getBoundingClientRect(); const rightAligned = r.right >= window.innerWidth - 8; const hasChatHint = /发送消息|关闭会话|私信/.test(el.textContent || ''); if (!rightAligned || !hasChatHint) continue; const score = r.width * r.height; if (score > bestScore) { bestScore = score; best = el; } } return best; } function findChatPanel() { if (chatPanel && document.contains(chatPanel) && isVisible(chatPanel)) return chatPanel; if (Date.now() < suppressUntil) return null; return findBySendInput() || findByPrivateLabel() || findByRightFloatingPanel(); } /** 找到面板内的左右分栏容器,并打上辅助 class */ function markSplitStructure(panel) { panel.querySelectorAll('.dy-split-row, .dy-split-col').forEach((el) => { el.classList.remove('dy-split-row', 'dy-split-col'); }); const pr = panel.getBoundingClientRect(); let best = null; let bestScore = 0; panel.querySelectorAll('div').forEach((el) => { const kids = [...el.children].filter((c) => c.tagName === 'DIV' && isVisible(c)); if (kids.length < 2) return; const r = el.getBoundingClientRect(); if (r.height < pr.height * 0.45) return; const horizontal = kids.every((k) => { const kr = k.getBoundingClientRect(); return kr.height >= r.height * 0.55; }); if (!horizontal) return; const score = r.width * r.height; if (score > bestScore) { bestScore = score; best = { row: el, cols: kids.slice(0, 2) }; } }); if (!best) return; best.row.classList.add('dy-split-row'); best.cols.forEach((col) => col.classList.add('dy-split-col')); } function refreshPanelLayout() { if (!chatPanel || !document.contains(chatPanel)) return; markSplitStructure(chatPanel); const listOnly = !hasSendInput(chatPanel); chatPanel.classList.toggle('dy-list-only', listOnly); log(listOnly ? '列表模式' : '会话模式'); } function isSessionCloseControl(el) { const text = (el.textContent || '').trim(); const aria = (el.getAttribute('aria-label') || '').trim(); return text === '关闭会话' || aria === '关闭会话'; } function isPanelCloseControl(el) { if (!el || !chatPanel || !chatPanel.contains(el)) return false; if (isSessionCloseControl(el)) return false; const text = (el.textContent || '').trim(); const aria = (el.getAttribute('aria-label') || '').trim(); const title = (el.getAttribute('title') || '').trim(); if (text === '×' || text === '✕' || text === 'X') return true; if (/^关闭$/.test(text) || /^关闭私信$/.test(text)) return true; if (/close/i.test(aria + title) && !/会话/.test(aria + title)) return true; const cls = typeof el.className === 'string' ? el.className : ''; if (/close|Close/.test(cls) && !isSessionCloseControl(el)) return true; const btn = el.closest('button, [role="button"]') || el; if (!chatPanel.contains(btn)) return false; const r = btn.getBoundingClientRect(); const pr = chatPanel.getBoundingClientRect(); const nearTopRight = r.top - pr.top < 72 && pr.right - r.right < 96; const compact = r.width <= 52 && r.height <= 52; return nearTopRight && compact; } function bindPanelWatcher(panel) { if (panelWatcher) panelWatcher.disconnect(); panelWatcher = new MutationObserver(() => { if (!chatPanel) return; if (!document.contains(chatPanel) || !isVisible(chatPanel)) { releasePanel(false); return; } refreshPanelLayout(); }); panelWatcher.observe(panel, { attributes: true, attributeFilter: ['style', 'class', 'hidden', 'aria-hidden'], childList: true, subtree: true, }); panelWatcher.observe(document.body, { childList: true, subtree: true }); } function dockPanel(panel) { if (!panel || panel === chatPanel) return; chatPanel = panel; panel.classList.add('dy-chat-docked'); root.classList.add('dy-split-active'); applyCollapsed(state.collapsed); applyWidth(widthFromRatio()); bindPanelWatcher(panel); requestAnimationFrame(refreshPanelLayout); setTimeout(refreshPanelLayout, 200); log('已接管并分屏'); } function releasePanel(fromUserClose) { if (chatPanel) { chatPanel.classList.remove('dy-chat-docked', 'dy-collapsed', 'dy-list-only'); chatPanel.querySelectorAll('.dy-split-row, .dy-split-col').forEach((el) => { el.classList.remove('dy-split-row', 'dy-split-col'); }); } chatPanel = null; root.classList.remove('dy-split-active', 'dy-split-collapsed'); if (panelWatcher) { panelWatcher.disconnect(); panelWatcher = null; } if (fromUserClose) suppressDock(5000); log('面板已释放'); } document.addEventListener('click', (e) => { if (!chatPanel) return; const target = e.target.closest('button, [role="button"], svg, span, div'); if (!target || !chatPanel.contains(target)) return; if (isSessionCloseControl(target)) { setTimeout(refreshPanelLayout, 80); setTimeout(refreshPanelLayout, 350); return; } if (!isPanelCloseControl(target)) return; suppressDock(5000); requestAnimationFrame(() => { setTimeout(() => { if (!chatPanel || !isVisible(chatPanel)) releasePanel(true); }, 150); }); }, true); let scheduled = false; function scan() { scheduled = false; if (chatPanel) { if (!document.contains(chatPanel) || !isVisible(chatPanel)) { releasePanel(false); return; } refreshPanelLayout(); return; } if (Date.now() < suppressUntil) return; const panel = findChatPanel(); if (panel) dockPanel(panel); } function schedule() { if (scheduled) return; scheduled = true; requestAnimationFrame(scan); } new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); applyCollapsed(state.collapsed); schedule(); let n = 0; const timer = setInterval(() => { schedule(); if (++n > 10) clearInterval(timer); }, 1000); console.log('[抖音聊天分屏] v1.2.0 已加载。默认 50% 分屏;关闭会话后列表自动铺满。'); })();