// ==UserScript== // @name MarkDone Lite // @namespace https://github.com/veightz/AI-MarkDone // @updateURL https://raw.githubusercontent.com/veightz/AI-MarkDone/main/userscript/markdone-lite.user.js // @downloadURL https://raw.githubusercontent.com/veightz/AI-MarkDone/main/userscript/markdone-lite.user.js // @version 0.1.202610161715 // @description ChatGPT 日用子集:视口跟随阅读器、大纲钉住、页面宽度、软刷新、消息步进、字数统计、复制 Markdown、Cmd/Ctrl+Enter 发送、设置面板(非全功能扩展) // @author veightz // @match *://chatgpt.com/* // @match *://chat.openai.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-idle // @inject-into content // ==/UserScript== /** * MarkDone Userscript Lite — thin page-DOM wrappers. * Does NOT load dist-chrome/content.js or chrome.* APIs. * Full extension remains the capability ceiling; this is daily reading UX only. */ (function () { 'use strict'; if (window.top != null && window.self !== window.top) return; if (window.__mdliteBooted) return; window.__mdliteBooted = true; var VERSION = '0.1.202610161715'; var NS = 'mdlite'; var PIN_KEY = 'mdliteReaderOutlinePinned'; var NARROW_PX = 720; var HYSTERESIS_MS = 160; var REF_RATIO = 0.35; // ---- storage (GM → localStorage fallback) ---- function storeGet(key, def) { try { if (typeof GM_getValue === 'function') return GM_getValue(key, def); } catch (_) { /* ignore */ } try { var raw = localStorage.getItem(key); if (raw == null) return def; return JSON.parse(raw); } catch (_) { return def; } } function storeSet(key, val) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, val); return true; } } catch (_) { /* ignore */ } try { localStorage.setItem(key, JSON.stringify(val)); return true; } catch (_) { return false; } } // ---- toast ---- function toast(text, opts) { opts = opts || {}; var el = document.createElement('div'); el.className = NS + '-toast'; el.setAttribute('role', 'status'); var span = document.createElement('span'); span.textContent = text; el.appendChild(span); if (opts.actionLabel && typeof opts.onAction === 'function') { var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = opts.actionLabel; btn.addEventListener('click', function () { opts.onAction(); el.remove(); }); el.appendChild(btn); } document.documentElement.appendChild(el); setTimeout(function () { el.remove(); }, opts.durationMs || 5000); } // ---- DOM discovery (Lite; not full PageIndex) ---- // Mirrors extension ChatGPTAdapter.getMessageSelector(): two observed DOM shapes. var ASSISTANT_SELECTOR = [ '[data-message-author-role="assistant"]', '[data-chatgpt-search-unit-key$=":assistant"]', ].join(','); var CONTENT_SELECTOR = '.markdown.prose, [data-markdown-text-style="assistant-message"], .markdown, .prose, [class*="markdown"]'; var ACTIVE_PAGE_ATTR = 'data-app-shell-active-page'; // Thin active-main resolver (upstream idea only — not a port of its TS module). // Prefer the unique main under data-app-shell-active-page="true"; else first visible main. var cachedActiveMain = null; var cachedActiveEpoch = 0; function isDisplayed(el) { if (!(el instanceof HTMLElement) || !el.isConnected) return false; try { if (window.getComputedStyle(el).display === 'none') return false; } catch (_) { return false; } var rect = el.getBoundingClientRect(); return Number.isFinite(rect.width) && Number.isFinite(rect.height); } function resolveActiveMain() { var owners = document.querySelectorAll('[' + ACTIVE_PAGE_ATTR + ']'); if (owners.length > 0) { var active = []; for (var i = 0; i < owners.length; i++) { var o = owners[i]; if (o instanceof HTMLElement && o.getAttribute(ACTIVE_PAGE_ATTR) === 'true') active.push(o); } if (active.length === 1) { var owner = active[0]; var nested = false; for (var j = 0; j < owners.length; j++) { var other = owners[j]; if (other !== owner && (owner.contains(other) || other.contains(owner))) { nested = true; break; } } if (!nested) { var mains = owner.matches('main') ? [owner] : Array.prototype.slice.call(owner.querySelectorAll('main')); if (mains.length === 1 && mains[0] instanceof HTMLElement) { cachedActiveMain = mains[0]; return cachedActiveMain; } } } } var allMains = document.querySelectorAll('main'); for (var k = 0; k < allMains.length; k++) { var m = allMains[k]; if (isDisplayed(m)) { cachedActiveMain = m; return cachedActiveMain; } } cachedActiveMain = allMains[0] instanceof HTMLElement ? allMains[0] : null; return cachedActiveMain; } function getActiveMain() { if (cachedActiveMain && cachedActiveMain.isConnected) { // Re-check marker still matches when present. var owner = cachedActiveMain.closest('[' + ACTIVE_PAGE_ATTR + ']'); if (!owner || owner.getAttribute(ACTIVE_PAGE_ATTR) === 'true') return cachedActiveMain; } return resolveActiveMain(); } function invalidateActiveMain() { cachedActiveMain = null; cachedActiveEpoch += 1; } function assistantRoots() { var scope = getActiveMain() || document; var nodes = scope.querySelectorAll(ASSISTANT_SELECTOR); var out = []; for (var i = 0; i < nodes.length; i++) { var n = nodes[i]; if (!(n instanceof HTMLElement)) continue; if (!n.isConnected) continue; if (n.closest('[class^="' + NS + '-"],[class*=" ' + NS + '-"]')) continue; // our own clones // Prefer outermost match (search-unit wraps role nodes on newer DOM). var outer = n.parentElement && n.parentElement.closest(ASSISTANT_SELECTOR); if (outer) continue; out.push(n); } return out; } function collectRounds() { var roots = assistantRoots(); var scope = getActiveMain() || document; var stopVisible = !!scope.querySelector('button[data-testid="stop-button"]'); var rounds = []; for (var i = 0; i < roots.length; i++) { var root = roots[i]; if (root.querySelector('.result-streaming, [data-is-streaming="true"]')) continue; if (stopVisible && i === roots.length - 1) continue; var rect = root.getBoundingClientRect(); if (!Number.isFinite(rect.top) || (rect.height <= 0 && rect.width <= 0)) continue; rounds.push({ index: i, root: root, top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, id: root.getAttribute('data-message-id') || root.getAttribute('data-chatgpt-search-unit-key') || root.id || String(i), }); } return rounds; } function resolvePrimary(rounds) { var vh = window.innerHeight || 1; var lineY = Math.round(vh * REF_RATIO); var best = null; var bestOverlap = 0; var nearest = null; var nearestDist = Infinity; for (var i = 0; i < rounds.length; i++) { var r = rounds[i]; if (r.bottom <= 0 || r.top >= vh) continue; var band = Math.max(12, Math.round(vh * 0.02)); var overlap = Math.min(r.bottom, lineY + band) - Math.max(r.top, lineY - band); if (overlap > bestOverlap) { bestOverlap = overlap; best = r; } var dist = lineY < r.top ? r.top - lineY : lineY > r.bottom ? lineY - r.bottom : 0; if (dist < nearestDist) { nearestDist = dist; nearest = r; } } return best || nearest; } // ---- outline from message HTML ---- function buildOutline(root) { var items = []; if (!root) return items; var content = root.querySelector(CONTENT_SELECTOR) || root; var nodes = content.querySelectorAll('h1,h2,h3,h4,p,li'); var seen = Object.create(null); for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; var text = (node.textContent || '').trim().replace(/\s+/g, ' '); if (!text || text.length > 120) continue; var level = 2; var tag = node.tagName.toLowerCase(); if (tag === 'h1') level = 1; else if (tag === 'h2') level = 2; else if (tag === 'h3' || tag === 'h4') level = 3; else { var m = text.match(/^(\d+)[\.、.]\s+\S/) || text.match(/^([一二三四五六七八九十]+)[、..]\s*\S/); if (!m) continue; level = 2; } var key = level + ':' + text.slice(0, 40); if (seen[key]) continue; seen[key] = true; if (!node.id) node.id = NS + '-h-' + items.length + '-' + Math.abs(hash(text)); items.push({ id: node.id, text: text.slice(0, 80), level: level, el: node }); if (items.length >= 80) break; } return items; } function hash(s) { var h = 0; for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; } // ---- preferences ---- var WIDTH_KEY = 'mdlitePageWidth'; var READER_WIDTH_KEY = 'mdliteReaderWidth'; var WIDTH_ORDER = ['normal', 'wide', 'full']; var WIDTH_LABEL = { normal: '标准', wide: '较宽', full: '铺满' }; function normWidth(v) { return WIDTH_ORDER.indexOf(v) >= 0 ? v : 'normal'; } function nextWidth(v) { return WIDTH_ORDER[(WIDTH_ORDER.indexOf(normWidth(v)) + 1) % WIDTH_ORDER.length]; } var pageWidth = normWidth(storeGet(WIDTH_KEY, 'normal')); var readerWidth = normWidth(storeGet(READER_WIDTH_KEY, 'normal')); var outlinePinned = storeGet(PIN_KEY, false) === true; // Extra Lite settings (all have explicit defaults; "重置" restores them). var SEND_KEY = 'mdliteSendCmdEnter'; var REFRESH_FAB_KEY = 'mdliteShowRefreshFab'; var CHIP_KEY = 'mdliteShowChip'; var sendCmdEnter = storeGet(SEND_KEY, false) === true; // default OFF var showRefreshFab = storeGet(REFRESH_FAB_KEY, false) === true; // default OFF var showChip = storeGet(CHIP_KEY, true) !== false; // default ON var STEP_KEY = 'mdliteStepNav'; var STEP_KEYS_KEY = 'mdliteStepKeys'; var WORDS_KEY = 'mdliteWordCount'; var COPYMD_KEY = 'mdliteCopyMd'; var stepNav = storeGet(STEP_KEY, true) !== false; // default ON var stepKeys = storeGet(STEP_KEYS_KEY, true) !== false; // default ON (needs stepNav) var showWords = storeGet(WORDS_KEY, true) !== false; // default ON var copyMdOn = storeGet(COPYMD_KEY, true) !== false; // default ON var IS_MAC = /Mac|iPhone|iPad/i.test((navigator.platform || '') + ' ' + (navigator.userAgent || '')); var SEND_KEY_LABEL = IS_MAC ? '⌘+Enter' : 'Ctrl+Enter'; // ---- styles ---- // Page-width: mirror extension ChatGPTPageWidthController + 2026 ChatGPT CSS vars. // ChatGPT drives column width via per-element --thread-content-max-width (and // --thread-content-width for tables). Absolute px / none beats min(1200px,100%), // which collapses to the already-narrow containing block and looks like a no-op. var PAGE_WIDTH_SELECTORS = [ '[class*="thread-content-max-width"]', '[class*="max-w-(--thread-content-max-width)"]', '[class*="max-w-[var(--thread-content-max-width)]"]', '.text-token-text-primary > div > div', 'main [class*="xl:max-w-[48rem]"]', 'main [class*="md:max-w-3xl"]', '.text-base.mx-auto', ]; var PAGE_WIDTH_CSS_TARGETS = [ '[class*="thread-content-max-width"]', '[class*="max-w-(--thread-content-max-width)"]', '[class*="max-w-[var(--thread-content-max-width)]"]', '.text-token-text-primary > div > div', 'main [class*="xl:max-w-[48rem]"]', 'main [class*="md:max-w-3xl"]', '.text-base.mx-auto', 'form[data-type="unified-composer"]', 'form:has(#prompt-textarea)', 'form:has([data-testid="composer"])', ]; var WIDTH_PX = { wide: 1200, full: null }; var widthStyleEl = null; var widthFoundCache = false; function findWidthContainers() { var seen = []; var pushUnique = function (node) { if (!node || seen.indexOf(node) >= 0) return; // Prefer containers inside the active main when one exists. var active = getActiveMain(); if (active && node instanceof HTMLElement && !active.contains(node) && node !== active) { // Allow form/composer nodes that sit outside main but still drive width. var tag = (node.tagName || '').toLowerCase(); if (tag !== 'form' && !node.closest('form')) return; } seen.push(node); }; var scope = getActiveMain() || document; for (var i = 0; i < PAGE_WIDTH_SELECTORS.length; i++) { try { scope.querySelectorAll(PAGE_WIDTH_SELECTORS[i]).forEach(pushUnique); } catch (_) { /* invalid selector in older engines — ignore */ } } // Composer often lives outside main — still scan document for form targets. try { document.querySelectorAll('form[data-type="unified-composer"], form:has(#prompt-textarea), form:has([data-testid="composer"])').forEach(pushUnique); } catch (_) { /* :has unsupported — ignore */ } // Prefer nodes that actually constrain width (pixel max-width or the CSS var class). var constraining = seen.filter(function (el) { var cls = typeof el.className === 'string' ? el.className : ''; if (cls.indexOf('thread-content-max-width') >= 0) return true; var mw = window.getComputedStyle(el).maxWidth; var n = Number.parseFloat(mw); return mw && mw !== 'none' && mw !== '100%' && Number.isFinite(n) && n > 0 && n < window.innerWidth; }); return constraining.length ? constraining : seen; } function hasWidthContainer() { var nodes = findWidthContainers(); widthFoundCache = nodes.length > 0; return widthFoundCache; } function buildWidthCss(mode) { if (mode === 'normal') return ''; var value = mode === 'full' ? 'none' : WIDTH_PX.wide + 'px'; var varValue = mode === 'full' ? '100%' : WIDTH_PX.wide + 'px'; var targets = PAGE_WIDTH_CSS_TARGETS.join(','); // Direct rules (no html[] prefix): same reliability as ChatGPTPageWidthController — // SPA can strip dataset flags; style text is what must keep working. var rules = [ ':root,html,body,main{--thread-content-max-width:' + varValue + '!important;--thread-content-width:' + varValue + '!important}', targets + '{--thread-content-max-width:' + varValue + '!important;--thread-content-width:' + varValue + '!important;max-width:' + value + '!important}', ]; if (mode === 'full') rules.push(targets + '{width:100%!important}'); return rules.join(''); } function ensureWidthStyle() { if (widthStyleEl && widthStyleEl.isConnected) return widthStyleEl; var existing = document.getElementById(NS + '-width-style'); if (existing) { widthStyleEl = existing; return widthStyleEl; } widthStyleEl = document.createElement('style'); widthStyleEl.id = NS + '-width-style'; document.documentElement.appendChild(widthStyleEl); return widthStyleEl; } function ensureStyle() { var existing = document.getElementById(NS + '-style'); if (existing && existing.isConnected) return; var s = document.createElement('style'); s.id = NS + '-style'; var P = '.' + NS; function dark(scope) { var Q = scope + ' ' + P; return [ Q + '-chip,' + Q + '-fab button.secondary,' + Q + '-panel,' + Q + '-panel__head,' + Q + '-rail,' + Q + '-menu{', 'background:#1e1e1e;color:#f2f2f2;border-color:rgba(255,255,255,.14)}', Q + '-chip:hover,' + Q + '-rail__list button:hover,' + Q + '-menu button:hover{background:#2a2a2a}', Q + '-menu button{color:#f2f2f2}', Q + '-menu button[aria-pressed="true"],' + Q + '-menu button[aria-checked="true"]{background:#f2f2f2;color:#111}', Q + '-menu small{color:#aaa}', Q + '-settings__card{background:#1e1e1e;color:#f2f2f2;border-color:rgba(255,255,255,.14)}', Q + '-settings button[aria-pressed="true"],' + Q + '-settings button[aria-checked="true"]{background:#f2f2f2;color:#111;border-color:#f2f2f2}', ].join(''); } s.textContent = [ P + '-chip{position:fixed;z-index:2147483000;', 'display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;', 'border:1px solid rgba(0,0,0,.12);background:#fff;color:#111;font:12px/1.2 system-ui,sans-serif;', 'box-shadow:0 4px 14px rgba(0,0,0,.12);cursor:pointer;user-select:none}', P + '-chip[hidden],' + P + '-menu[hidden]{display:none!important}', P + '-chip[data-fixed="1"]{right:16px;bottom:96px;top:auto!important;left:auto!important}', P + '-chip:hover{background:#f6f7f9}', P + '-chip{gap:2px;padding:3px 6px}', P + '-chip button{border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;padding:3px 6px;border-radius:999px}', P + '-chip button:hover{background:rgba(127,127,127,.18)}', P + '-chip button[hidden]{display:none!important}', P + '-fab button.step{padding:8px 10px}', P + '-panel__head small{opacity:.65;font-size:11px;white-space:nowrap}', P + '-fab{position:fixed;right:16px;bottom:40px;z-index:2147483000;', 'display:inline-flex;gap:6px;align-items:center}', P + '-fab button{padding:8px 12px;border-radius:10px;border:1px solid rgba(0,0,0,.14);', 'background:#111;color:#fff;font:12px/1.2 system-ui,sans-serif;cursor:pointer;', 'box-shadow:0 4px 14px rgba(0,0,0,.18)}', P + '-fab button.secondary{background:#fff;color:#111}', P + '-menu{position:fixed;right:16px;bottom:84px;z-index:2147483001;min-width:220px;', 'padding:8px;border-radius:12px;border:1px solid rgba(0,0,0,.12);background:#fff;color:#111;', 'box-shadow:0 10px 30px rgba(0,0,0,.2);font:13px/1.35 system-ui,sans-serif}', P + '-menu__row{display:flex;align-items:center;gap:6px;padding:2px 0}', P + '-menu__label{flex:0 0 auto;padding:6px 8px;opacity:.8}', P + '-menu button{display:block;width:100%;text-align:left;border:0;background:transparent;', 'padding:7px 10px;border-radius:8px;cursor:pointer;color:#111;font:13px/1.3 system-ui,sans-serif}', P + '-menu__row button{width:auto;flex:1;text-align:center;padding:6px 8px;border:1px solid rgba(127,127,127,.25)}', P + '-menu button:hover{background:#f2f3f5}', P + '-menu button[aria-pressed="true"],' + P + '-menu button[aria-checked="true"]{background:#111;color:#fff}', P + '-menu hr{border:0;border-top:1px solid rgba(127,127,127,.2);margin:6px 0}', P + '-menu small{display:block;padding:4px 10px;color:#666;font-size:11px}', P + '-panel{position:fixed;top:0;right:0;bottom:0;width:min(420px,92vw);z-index:2147483001;', 'display:flex;flex-direction:column;background:#fafafa;color:#111;', 'border-left:1px solid rgba(0,0,0,.12);box-shadow:-8px 0 24px rgba(0,0,0,.12);', 'font:14px/1.5 system-ui,sans-serif}', P + '-panel[data-width="wide"]{width:min(760px,94vw)}', P + '-panel[data-width="full"]{width:96vw}', P + '-panel[hidden]{display:none!important}', P + '-panel__head{display:flex;align-items:center;gap:8px;padding:12px 14px;', 'border-bottom:1px solid rgba(0,0,0,.08);background:#fff}', P + '-panel__head strong{flex:1}', P + '-panel__body{flex:1;overflow:auto;padding:16px 18px}', P + '-panel[data-rail-pinned="1"] ' + P + '-panel__body{margin-right:200px}', P + '-panel__body h1,' + P + '-panel__body h2,' + P + '-panel__body h3{scroll-margin-top:12px}', P + '-panel__body pre{white-space:pre-wrap;overflow:auto}', P + '-panel__body img{max-width:100%}', P + '-rail{position:absolute;top:56px;right:0;bottom:0;width:28px;', 'border-left:1px solid rgba(0,0,0,.06);background:rgba(255,255,255,.92);', 'overflow:hidden;transition:width .15s ease}', P + '-rail[data-pinned="1"],' + P + '-rail:hover,' + P + '-rail:focus-within{width:200px}', P + '-rail__head{display:flex;align-items:center;justify-content:flex-end;padding:6px}', P + '-rail__list{list-style:none;margin:0;padding:4px 8px 12px;overflow:auto;height:calc(100% - 36px)}', P + '-rail__list button{display:block;width:100%;text-align:left;border:0;background:transparent;', 'padding:4px 6px;border-radius:6px;font:11px/1.3 system-ui,sans-serif;color:inherit;cursor:pointer;', 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', P + '-rail__list button:hover{background:rgba(0,0,0,.06)}', P + '-rail__list button[data-level="1"]{font-weight:600}', P + '-rail__list button[data-level="3"]{padding-left:14px;opacity:.9}', P + '-pin{border:0;background:transparent;color:inherit;cursor:pointer;padding:4px 6px;border-radius:6px;font:12px/1.2 system-ui,sans-serif}', P + '-pin[aria-pressed="true"]{background:#111;color:#fff}', P + '-fab button[hidden]{display:none!important}', P + '-settings[hidden]{display:none!important}', P + '-settings{position:fixed;inset:0;z-index:2147483003;display:flex;align-items:center;', 'justify-content:center;background:rgba(0,0,0,.35);font:14px/1.45 system-ui,sans-serif}', P + '-settings__card{width:min(440px,94vw);max-height:90vh;overflow:auto;border-radius:14px;', 'background:#fff;color:#111;border:1px solid rgba(0,0,0,.12);box-shadow:0 16px 48px rgba(0,0,0,.3);padding:14px 16px}', P + '-settings__head{display:flex;align-items:center;gap:8px;margin-bottom:6px}', P + '-settings__head strong{flex:1;font-size:15px}', P + '-settings__row{display:flex;align-items:center;gap:10px;padding:9px 0;border-top:1px solid rgba(127,127,127,.18)}', P + '-settings__txt{flex:1;min-width:0}', P + '-settings__txt small{display:block;opacity:.65;font-size:11px;margin-top:2px}', P + '-settings button{border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;', 'border-radius:8px;padding:5px 10px;cursor:pointer;font:12px/1.2 system-ui,sans-serif}', P + '-settings button[aria-pressed="true"],' + P + '-settings button[aria-checked="true"]{background:#111;color:#fff;border-color:#111}', P + '-settings__seg{display:inline-flex;gap:4px}', P + '-settings__foot{display:flex;align-items:center;gap:8px;padding-top:10px;border-top:1px solid rgba(127,127,127,.18)}', P + '-settings__foot small{flex:1;opacity:.6;font-size:11px}', P + '-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483002;', 'display:flex;gap:10px;align-items:center;max-width:min(520px,92vw);padding:10px 14px;', 'border-radius:10px;background:#111;color:#fff;font:13px/1.3 system-ui,sans-serif;', 'box-shadow:0 8px 24px rgba(0,0,0,.25)}', P + '-toast button{border:0;border-radius:6px;padding:4px 8px;cursor:pointer;', 'background:#fff;color:#111;font:12px/1.2 system-ui,sans-serif}', dark('html.dark'), '@media (prefers-color-scheme: dark){' + dark('html:not(.light)') + '}', ].join(''); document.documentElement.appendChild(s); } // ---- page width ---- function applyPageWidth() { var html = document.documentElement; if (pageWidth === 'normal') { if (html.hasAttribute('data-mdlite-width')) html.removeAttribute('data-mdlite-width'); } else { html.setAttribute('data-mdlite-width', pageWidth); } var style = ensureWidthStyle(); style.textContent = buildWidthCss(pageWidth); hasWidthContainer(); } function setPageWidth(mode, silent) { pageWidth = normWidth(mode); storeSet(WIDTH_KEY, pageWidth); applyPageWidth(); syncFab(); syncMenu(); syncSettings(); scheduleChip(); if (!silent) { var note = pageWidth === 'normal' ? '' : (widthFoundCache ? '' : '(未找到宽度容器,若没变宽请截图)'); toast('页面宽度:' + WIDTH_LABEL[pageWidth] + note, { durationMs: 1800 }); } } // ---- reader panel ---- var panel = null; var panelBody = null; var rail = null; var railList = null; var pinBtn = null; var readerWidthBtn = null; var readerCountEl = null; var readerCopyBtn = null; var currentRoot = null; function ensurePanel() { if (panel) { if (!panel.isConnected) document.documentElement.appendChild(panel); return panel; } panel = document.createElement('aside'); panel.className = NS + '-panel'; panel.hidden = true; panel.setAttribute('aria-label', 'MarkDone Lite 阅读器'); var head = document.createElement('div'); head.className = NS + '-panel__head'; var title = document.createElement('strong'); title.textContent = 'MarkDone Lite 阅读器'; readerWidthBtn = document.createElement('button'); readerWidthBtn.type = 'button'; readerWidthBtn.className = NS + '-pin'; readerWidthBtn.addEventListener('click', function () { readerWidth = nextWidth(readerWidth); storeSet(READER_WIDTH_KEY, readerWidth); syncReaderWidth(); }); var close = document.createElement('button'); close.type = 'button'; close.className = NS + '-pin'; close.textContent = '关闭'; close.addEventListener('click', function () { panel.hidden = true; scheduleChip(); }); readerCountEl = document.createElement('small'); readerCopyBtn = document.createElement('button'); readerCopyBtn.type = 'button'; readerCopyBtn.className = NS + '-pin'; readerCopyBtn.textContent = '复制 MD'; readerCopyBtn.title = '复制这条回复的 Markdown'; readerCopyBtn.addEventListener('click', function () { if (currentRoot) copyMarkdownOf(currentRoot); }); head.appendChild(title); head.appendChild(readerCountEl); head.appendChild(readerCopyBtn); head.appendChild(readerWidthBtn); head.appendChild(close); panelBody = document.createElement('div'); panelBody.className = NS + '-panel__body'; rail = document.createElement('nav'); rail.className = NS + '-rail'; rail.setAttribute('aria-label', '大纲'); var railHead = document.createElement('div'); railHead.className = NS + '-rail__head'; pinBtn = document.createElement('button'); pinBtn.type = 'button'; pinBtn.className = NS + '-pin'; pinBtn.addEventListener('click', function () { setOutlinePinned(!outlinePinned, true); }); railHead.appendChild(pinBtn); railList = document.createElement('div'); railList.className = NS + '-rail__list'; rail.appendChild(railHead); rail.appendChild(railList); panel.appendChild(head); panel.appendChild(panelBody); panel.appendChild(rail); document.documentElement.appendChild(panel); syncPinUi(); syncReaderWidth(); return panel; } function syncReaderWidth() { if (!panel || !readerWidthBtn) return; panel.dataset.width = readerWidth; readerWidthBtn.textContent = '宽度·' + WIDTH_LABEL[readerWidth]; readerWidthBtn.title = '阅读器宽度:标准 → 较宽 → 铺满'; } function setOutlinePinned(v, silent) { outlinePinned = !!v; storeSet(PIN_KEY, outlinePinned); syncPinUi(); syncMenu(); syncSettings(); if (!silent) toast(outlinePinned ? '大纲已钉住(阅读器右侧常显)' : '大纲改为悬停展开', { durationMs: 2000 }); } function syncPinUi() { if (!rail || !pinBtn) return; rail.dataset.pinned = outlinePinned ? '1' : '0'; panel.dataset.railPinned = outlinePinned ? '1' : '0'; pinBtn.setAttribute('aria-pressed', outlinePinned ? 'true' : 'false'); pinBtn.title = outlinePinned ? '已钉住,点击改为悬停展开' : '钉住大纲'; pinBtn.setAttribute('aria-label', pinBtn.title); pinBtn.textContent = outlinePinned ? '📌' : '📍'; } function openReader(root) { ensurePanel(); currentRoot = root; var source = root.querySelector(CONTENT_SELECTOR) || root; var clone = source.cloneNode(true); // Drop interactive chrome from clone. var junk = clone.querySelectorAll('button, textarea, input, [class*="' + NS + '-"]'); for (var i = 0; i < junk.length; i++) junk[i].remove(); panelBody.innerHTML = ''; panelBody.appendChild(clone); var items = buildOutline(panelBody); railList.innerHTML = ''; for (var j = 0; j < items.length; j++) { (function (item) { var b = document.createElement('button'); b.type = 'button'; b.dataset.level = String(item.level); b.textContent = item.text; b.title = item.text; b.addEventListener('click', function () { var target = panelBody.querySelector('#' + CSS.escape(item.id)); if (target) target.scrollIntoView({ block: 'start', behavior: 'smooth' }); }); railList.appendChild(b); })(items[j]); } if (!items.length) { var empty = document.createElement('small'); empty.style.cssText = 'display:block;padding:4px 6px;opacity:.6;white-space:normal'; empty.textContent = '此回复无标题/编号段落'; railList.appendChild(empty); } panelBody.scrollTop = 0; panel.hidden = false; syncReaderExtras(); syncPinUi(); syncReaderWidth(); closeMenu(); } function openReaderForCurrent() { var target = bound && bound.root && bound.root.isConnected ? bound.root : null; if (!target) { var p = resolvePrimary(collectRounds()); if (p) target = p.root; } if (!target) { var all = assistantRoots(); if (all.length) target = all[all.length - 1]; } if (!target) { toast('没在当前页面找到助手回复(可能是新对话,或 ChatGPT DOM 改版)。请截图反馈。', { durationMs: 6000 }); return; } openReader(target); } // =================================================================== // Message step navigation / CJK word count / copy Markdown (Lite) // All DOM discovery is scoped to the active page main via assistantRoots(). // =================================================================== // ---- word count (CJK aware; code blocks, math and UI chrome excluded) ---- var CJK_RE = new RegExp('[\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\u3040-\\u30ff\\u{20000}-\\u{2fa1f}]', 'gu'); var WORD_RE = /[A-Za-z0-9\u00C0-\u024F\uac00-\ud7af]+(?:['’\-.][A-Za-z0-9\u00C0-\u024F\uac00-\ud7af]+)*/g; var SKIP_COUNT_SEL = 'pre, .katex, .katex-display, button, svg, script, style, [class*="' + NS + '-"]'; function markdownRoots(root) { var all = root.querySelectorAll('.markdown'); var out = []; for (var i = 0; i < all.length; i++) { var m = all[i]; if (m.parentElement && m.parentElement.closest('.markdown') && root.contains(m.parentElement.closest('.markdown'))) continue; out.push(m); } if (out.length) return out; return [root.querySelector(CONTENT_SELECTOR) || root]; } function countText(text) { var cjk = (text.match(CJK_RE) || []).length; var words = (text.replace(CJK_RE, ' ').match(WORD_RE) || []).length; return { cjk: cjk, words: words, total: cjk + words }; } var COUNT_SEP = { p: 1, div: 1, li: 1, tr: 1, td: 1, th: 1, br: 1, h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, table: 1, blockquote: 1, section: 1, article: 1, hr: 1 }; // Collect countable text; block/cell boundaries become spaces, inline tags do not // (so "foobar" stays one word). Code blocks, math and UI chrome are skipped. function collectCountText(node, out) { if (node.nodeType === 3) { out.push(node.nodeValue); return; } if (node.nodeType !== 1) return; if (node.matches(SKIP_COUNT_SEL)) return; var sep = COUNT_SEP[node.tagName.toLowerCase()] ? ' ' : ''; if (sep) out.push(sep); for (var c = node.firstChild; c; c = c.nextSibling) collectCountText(c, out); if (sep) out.push(sep); } function countWordsOf(root) { var cjk = 0; var words = 0; var roots = markdownRoots(root); for (var r = 0; r < roots.length; r++) { var parts = []; collectCountText(roots[r], parts); var c = countText(parts.join('')); cjk += c.cjk; words += c.words; } return { cjk: cjk, words: words, total: cjk + words }; } var countCache = { root: null, at: 0, val: null }; function cachedCount(root) { var now = Date.now(); if (countCache.root === root && now - countCache.at < 1000) return countCache.val; countCache = { root: root, at: now, val: countWordsOf(root) }; return countCache.val; } function fmtCount(c) { return c.total.toLocaleString('en-US') + ' 字'; } function countTitle(c) { return '中日文 ' + c.cjk + ' 字 + 英文 ' + c.words + ' 词(不含代码块与公式)'; } function syncChipLabel(round) { if (!chipMain) return; var label = '📖 阅读器'; var title = 'MarkDone Lite:在侧栏阅读这条回复(含大纲)'; if (showWords && round && round.root) { var c = cachedCount(round.root); label += ' · ' + fmtCount(c); title += '\n' + countTitle(c); } if (chipMain.textContent !== label) chipMain.textContent = label; chipMain.title = title; if (chipCopy) chipCopy.hidden = !copyMdOn; } function syncReaderExtras() { if (readerCountEl) { if (showWords && currentRoot && currentRoot.isConnected) { var c = countWordsOf(currentRoot); readerCountEl.textContent = fmtCount(c); readerCountEl.title = countTitle(c); readerCountEl.hidden = false; } else { readerCountEl.hidden = true; } } if (readerCopyBtn) readerCopyBtn.hidden = !copyMdOn; } // ---- HTML -> Markdown (own converter for ChatGPT's rendered message DOM) ---- var BLOCK_TAGS = { p: 1, div: 1, section: 1, article: 1, header: 1, footer: 1, main: 1, aside: 1, figure: 1, figcaption: 1, h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, li: 1, pre: 1, blockquote: 1, table: 1, hr: 1, details: 1, summary: 1, dl: 1, dt: 1, dd: 1, }; var SKIP_TAGS = { button: 1, svg: 1, script: 1, style: 1, noscript: 1, template: 1, textarea: 1, input: 1, select: 1 }; function texOf(el) { var ann = el.querySelector('annotation[encoding="application/x-tex"]'); return ann ? (ann.textContent || '').trim() : ''; } function isSkipped(el) { var tag = el.tagName.toLowerCase(); if (SKIP_TAGS[tag]) return true; var cls = typeof el.className === 'string' ? el.className : ''; if (cls.indexOf(NS + '-') >= 0) return true; if (el.getAttribute('aria-hidden') === 'true' && !el.classList.contains('katex')) return true; return false; } function codeLang(pre) { var codeEl = pre.querySelector('code'); var holders = [codeEl, pre.querySelector('[class*="language-"]'), pre]; for (var i = 0; i < holders.length; i++) { var h = holders[i]; if (!h) continue; var m = /(?:^|\s)language-([\w+#.\-]+)/.exec(typeof h.className === 'string' ? h.className : ''); if (m && m[1] !== 'plaintext' && m[1] !== 'text') return m[1]; } // ChatGPT header strip: a short single-word label before the code body. var cand = pre.querySelectorAll('div, span'); for (var j = 0; j < cand.length && j < 6; j++) { var el = cand[j]; if (el.querySelector('code, .cm-content, pre')) continue; var t = ''; for (var k = 0; k < el.childNodes.length; k++) { if (el.childNodes[k].nodeType === 3) t += el.childNodes[k].nodeValue; } t = t.trim(); if (t && t.length <= 24 && /^[A-Za-z][\w+#.\-]*$/.test(t)) return t.toLowerCase(); } return ''; } function codeText(pre) { var lines = pre.querySelectorAll('.cm-line'); if (lines.length) { var out = []; for (var i = 0; i < lines.length; i++) out.push(lines[i].textContent || ''); return out.join('\n'); } var codeEl = pre.querySelector('code'); var txt = (codeEl || pre).textContent || ''; return txt; } function fenceFor(code) { var fence = '```'; while (code.indexOf(fence) >= 0) fence += '`'; return fence; } function inlineMd(node) { if (node.nodeType === 3) return (node.nodeValue || '').replace(/\s+/g, ' '); if (node.nodeType !== 1) return ''; if (isSkipped(node)) return ''; var tag = node.tagName.toLowerCase(); var cls = typeof node.className === 'string' ? node.className : ''; if (cls.indexOf('katex-display') >= 0) { var td = texOf(node); return td ? '\n\n$$\n' + td + '\n$$\n\n' : ''; } if (node.classList.contains('katex')) { var ti = texOf(node); return ti ? '$' + ti + '$' : (node.textContent || ''); } if (tag === 'br') return ' \n'; if (tag === 'code') { var ct = node.textContent || ''; var tick = '`'; while (ct.indexOf(tick) >= 0) tick += '`'; var pad = ct.charAt(0) === '`' || ct.charAt(ct.length - 1) === '`' ? ' ' : ''; return tick + pad + ct + pad + tick; } var inner = inlineChildren(node); if (tag === 'strong' || tag === 'b') return wrapInline(inner, '**'); if (tag === 'em' || tag === 'i') return wrapInline(inner, '*'); if (tag === 'del' || tag === 's' || tag === 'strike') return wrapInline(inner, '~~'); if (tag === 'a') { var href = node.getAttribute('href') || ''; var txt = inner.trim(); if (!href || href.charAt(0) === '#' && !txt) return inner; return '[' + (txt || href) + '](' + href.replace(/\)/g, '%29').replace(/\s/g, '%20') + ')'; } if (tag === 'img') { var src = node.getAttribute('src') || ''; return src ? '![' + (node.getAttribute('alt') || '') + '](' + src + ')' : ''; } if (tag === 'sup') return '' + inner + ''; if (tag === 'sub') return '' + inner + ''; return inner; } function wrapInline(inner, mark) { var t = inner.replace(/^(\s*)([\s\S]*?)(\s*)$/, function (_, a, b, c) { return b ? a + mark + b + mark + c : a + c; }); return t; } function inlineChildren(el) { var out = ''; for (var i = 0; i < el.childNodes.length; i++) out += inlineMd(el.childNodes[i]); return out; } function isBlockEl(n) { if (n.nodeType !== 1) return false; var tag = n.tagName.toLowerCase(); if (BLOCK_TAGS[tag]) return true; return !!(n.classList && n.classList.contains('katex-display')); } // Convert the children of a container into an array of block strings. function blocksMd(parent) { var blocks = []; var buf = ''; function flush() { var t = buf.replace(/^[ \t]+/, '').replace(/[ \t]+$/g, ''); buf = ''; if (t.replace(/\s+/g, '') === '') return; blocks.push(t.replace(/^\n+|\n+$/g, '')); } for (var i = 0; i < parent.childNodes.length; i++) { var n = parent.childNodes[i]; if (n.nodeType === 1 && isSkipped(n)) continue; if (isBlockEl(n)) { flush(); var b = blockMd(n); for (var k = 0; k < b.length; k++) blocks.push(b[k]); } else { buf += inlineMd(n); } } flush(); return blocks; } function indentLines(text, pad) { return text.split('\n').map(function (l, i) { return i === 0 || l === '' ? l : pad + l; }).join('\n'); } function listMd(list, depthOrdered) { var ordered = list.tagName.toLowerCase() === 'ol'; var start = ordered ? parseInt(list.getAttribute('start') || '1', 10) : 1; if (!Number.isFinite(start)) start = 1; var items = []; var idx = 0; for (var i = 0; i < list.children.length; i++) { var li = list.children[i]; if (li.tagName.toLowerCase() !== 'li' || isSkipped(li)) continue; var marker = ordered ? (start + idx) + '. ' : '- '; idx++; var parts = blocksMd(li); var body = ''; for (var j = 0; j < parts.length; j++) { if (j > 0) { var prevIsList = /^(?:[-*] |\d+\. )/.test(parts[j]); body += prevIsList || /^(?:[-*] |\d+\. )/.test(parts[j - 1]) ? '\n' : '\n\n'; } body += parts[j]; } var pad = new Array(marker.length + 1).join(' '); items.push(marker + indentLines(body, pad)); } return items.join('\n'); } function tableMd(table) { var rows = table.querySelectorAll('tr'); var data = []; var aligns = []; for (var i = 0; i < rows.length; i++) { if (rows[i].closest('table') !== table) continue; var cells = rows[i].children; var row = []; for (var j = 0; j < cells.length; j++) { var c = cells[j]; var tag = c.tagName.toLowerCase(); if (tag !== 'th' && tag !== 'td') continue; var txt = inlineChildren(c).replace(/\s*\n\s*/g, '
').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim(); row.push(txt); if (data.length === 0) { var al = (c.getAttribute('align') || (c.style && c.style.textAlign) || '').toLowerCase(); aligns.push(al === 'center' ? ':---:' : al === 'right' ? '---:' : al === 'left' ? ':---' : '---'); } } if (row.length) data.push(row); } if (!data.length) return ''; var cols = 0; data.forEach(function (r) { if (r.length > cols) cols = r.length; }); while (aligns.length < cols) aligns.push('---'); var lines = []; data.forEach(function (r, idx) { while (r.length < cols) r.push(''); lines.push('| ' + r.join(' | ') + ' |'); if (idx === 0) lines.push('| ' + aligns.join(' | ') + ' |'); }); return lines.join('\n'); } function blockMd(el) { var tag = el.tagName.toLowerCase(); if (/^h[1-6]$/.test(tag)) { var t = inlineChildren(el).replace(/\s+/g, ' ').trim(); return t ? ['#'.repeat(parseInt(tag.charAt(1), 10)) + ' ' + t] : []; } if (tag === 'p' || tag === 'dt' || tag === 'summary' || tag === 'figcaption') { var pt = inlineChildren(el).replace(/^[ \t]+|[ \t]+$/g, '').replace(/^\n+|\n+$/g, ''); return pt.replace(/\s+/g, '') ? [pt] : []; } if (tag === 'ul' || tag === 'ol') { var lt = listMd(el); return lt ? [lt] : []; } if (tag === 'li') return blocksMd(el); // stray li outside a list if (tag === 'pre') { var code = codeText(el).replace(/\n$/, ''); var fence = fenceFor(code); return [fence + codeLang(el) + '\n' + code + '\n' + fence]; } if (tag === 'blockquote') { var inner = blocksMd(el).join('\n\n'); return inner ? [inner.split('\n').map(function (l) { return l ? '> ' + l : '>'; }).join('\n')] : []; } if (tag === 'table') { var tt = tableMd(el); return tt ? [tt] : []; } if (tag === 'hr') return ['---']; if (el.classList.contains('katex-display')) { var tex = texOf(el); return tex ? ['$$\n' + tex + '\n$$'] : []; } // generic container: div/section/... return blocksMd(el); } function htmlToMarkdown(rootEl) { var md = blocksMd(rootEl).join('\n\n'); return md.replace(/\n{3,}/g, '\n\n').trim() + '\n'; } function messageMarkdown(root) { var roots = markdownRoots(root); var parts = []; for (var i = 0; i < roots.length; i++) { var md = htmlToMarkdown(roots[i]).trim(); if (md) parts.push(md); } return parts.join('\n\n') + (parts.length ? '\n' : ''); } function writeClipboard(text) { function legacy() { var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'; document.documentElement.appendChild(ta); ta.select(); var ok = false; try { ok = document.execCommand('copy'); } catch (_) { ok = false; } ta.remove(); return ok; } try { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return legacy(); }); } } catch (_) { /* fall through */ } return Promise.resolve(legacy()); } function copyMarkdownOf(root) { closeMenu(); if (!root || !root.isConnected) { toast('这条回复已不在页面里,请重新选择。', { durationMs: 3000 }); return Promise.resolve(false); } var md = messageMarkdown(root); if (!md.trim()) { toast('这条回复里没找到可复制的内容。', { durationMs: 3000 }); return Promise.resolve(false); } return writeClipboard(md).then(function (ok) { toast(ok ? '已复制 Markdown(' + md.length.toLocaleString('en-US') + ' 字符)' : '复制失败:浏览器拒绝写入剪贴板(可先点一下页面再试)。', { durationMs: ok ? 2200 : 5000 }); return ok; }); } function copyCurrentMarkdown() { var target = null; if (panel && !panel.hidden && currentRoot && currentRoot.isConnected) target = currentRoot; if (!target && bound && bound.root && bound.root.isConnected) target = bound.root; if (!target) { var p = resolvePrimary(collectRounds()); if (p) target = p.root; } if (!target) { var all = assistantRoots(); if (all.length) target = all[all.length - 1]; } if (!target) { toast('没在当前页面找到助手回复。', { durationMs: 3000 }); return Promise.resolve(false); } return copyMarkdownOf(target); } // ---- message step navigation ---- function scrollContainerOf(el) { var n = el.parentElement; while (n && n !== document.documentElement) { var oy = ''; try { oy = window.getComputedStyle(n).overflowY; } catch (_) { oy = ''; } if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1) return n; n = n.parentElement; } return null; } function scrollToMessage(root) { var sc = scrollContainerOf(root); var topPad = 72; // clear ChatGPT's sticky header if (sc) { var delta = root.getBoundingClientRect().top - sc.getBoundingClientRect().top - topPad; try { sc.scrollTo({ top: sc.scrollTop + delta, behavior: 'smooth' }); return; } catch (_) { sc.scrollTop += delta; return; } } try { root.scrollIntoView({ block: 'start', behavior: 'smooth' }); } catch (_) { root.scrollIntoView(true); } } function stepTargets() { var roots = assistantRoots(); var out = []; for (var i = 0; i < roots.length; i++) { var r = roots[i].getBoundingClientRect(); if (r.height <= 0 && r.width <= 0) continue; out.push({ root: roots[i], top: r.top, bottom: r.bottom }); } return out; } function stepMessage(dir) { var list = stepTargets(); if (!list.length) { toast('没在当前页面找到助手回复。', { durationMs: 2500 }); return false; } // Anchor = message at the reading line (35% down); fall back to first message below it. var vh = window.innerHeight || 1; var lineY = Math.round(vh * REF_RATIO); var idx = -1; var primary = resolvePrimary(collectRounds()); if (primary) { for (var i = 0; i < list.length; i++) if (list[i].root === primary.root) { idx = i; break; } } if (idx < 0) { for (var j = 0; j < list.length; j++) if (list[j].bottom > lineY) { idx = j; break; } if (idx < 0) idx = list.length - 1; } var target = null; if (dir < 0) { // Mid-way through a tall message: first jump to its own start. target = list[idx].top < -24 ? list[idx] : list[idx - 1]; } else { target = list[idx].top > lineY + 8 && idx === 0 ? list[idx] : list[idx + 1]; } if (!target) { toast(dir < 0 ? '已经是第一条回复' : '已经是最后一条回复', { durationMs: 1400 }); return false; } scrollToMessage(target.root); if (panel && !panel.hidden) openReader(target.root); closeMenu(); return true; } function isTypingTarget(t) { if (!(t instanceof Element)) return false; if (t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]')) return true; if (t.closest('[role="textbox"], [role="combobox"], [role="searchbox"], [role="slider"], [role="spinbutton"], [role="listbox"], [role="menu"], [role="dialog"]')) return true; return false; } function onStepKeydown(e) { if (!stepNav || !stepKeys) return; if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; if (e.isComposing || e.keyCode === 229 || imeComposing) return; if (isTypingTarget(e.target) || isTypingTarget(document.activeElement)) return; if (settingsEl && !settingsEl.hidden) return; var sel = window.getSelection && window.getSelection(); if (sel && !sel.isCollapsed && String(sel).length > 0) return; // don't fight text-selection extension if (!stepTargets().length) return; e.preventDefault(); stepMessage(e.key === 'ArrowLeft' ? -1 : 1); } function setStepNav(v) { stepNav = !!v; storeSet(STEP_KEY, stepNav); syncFab(); syncMenu(); syncSettings(); } function setStepKeys(v) { stepKeys = !!v; storeSet(STEP_KEYS_KEY, stepKeys); syncSettings(); } function setShowWords(v) { showWords = !!v; storeSet(WORDS_KEY, showWords); countCache = { root: null, at: 0, val: null }; syncReaderExtras(); scheduleChip(); syncSettings(); } function setCopyMd(v) { copyMdOn = !!v; storeSet(COPYMD_KEY, copyMdOn); syncReaderExtras(); if (chipCopy) chipCopy.hidden = !copyMdOn; syncMenu(); syncSettings(); } // ---- viewport chip ---- var chip = null; var pending = null; var pendingSince = 0; var bound = null; var raf = 0; var chipMain = null; var chipCopy = null; function ensureChip() { if (chip) { if (!chip.isConnected) document.documentElement.appendChild(chip); return chip; } chip = document.createElement('div'); chip.className = NS + '-chip'; chip.hidden = true; chipMain = document.createElement('button'); chipMain.type = 'button'; chipMain.className = NS + '-chip__main'; chipMain.textContent = '📖 阅读器'; chipMain.title = 'MarkDone Lite:在侧栏阅读这条回复(含大纲)'; chipMain.addEventListener('click', function () { if (!bound || !bound.root) return; openReader(bound.root); }); chipCopy = document.createElement('button'); chipCopy.type = 'button'; chipCopy.className = NS + '-chip__copy'; chipCopy.textContent = '📋 MD'; chipCopy.title = '复制这条回复的 Markdown'; chipCopy.addEventListener('click', function (e) { e.stopPropagation(); if (!bound || !bound.root) return; copyMarkdownOf(bound.root); }); chip.appendChild(chipMain); chip.appendChild(chipCopy); document.documentElement.appendChild(chip); return chip; } function placeChip(round, fixed) { ensureChip(); if (!showChip || !round || (panel && !panel.hidden)) { chip.hidden = true; return; } chip.hidden = false; syncChipLabel(round); if (fixed) { chip.dataset.fixed = '1'; chip.style.top = ''; chip.style.left = ''; return; } chip.dataset.fixed = '0'; // Viewport (fixed) coords: ChatGPT scrolls an inner container, not window. var rect = round.root.getBoundingClientRect(); var vw = window.innerWidth; var vh = window.innerHeight; var cw = chip.offsetWidth || 90; var ch = chip.offsetHeight || 28; var top = Math.min(Math.max(rect.top, 64), Math.min(rect.bottom - ch, vh - ch - 110)); if (!Number.isFinite(top)) top = 64; var left = rect.right + 8; if (left + cw > vw - 8) left = Math.max(8, Math.min(rect.right, vw - 8) - cw - 8); chip.style.top = Math.round(top) + 'px'; chip.style.left = Math.round(left) + 'px'; } function refreshChip() { raf = 0; var rounds = collectRounds(); var primary = resolvePrimary(rounds); var narrow = window.innerWidth < NARROW_PX; var now = Date.now(); if (!primary) { bound = null; placeChip(null, false); return; } var same = bound && bound.root === primary.root && bound.id === primary.id; if (!same) { if (!pending || pending.root !== primary.root) { pending = primary; pendingSince = now; } if (now - pendingSince < HYSTERESIS_MS && bound && bound.root.isConnected) { placeChip(bound, narrow); if (!raf) setTimeout(scheduleChip, HYSTERESIS_MS); return; } bound = primary; pending = null; } placeChip(bound, narrow); } function scheduleChip() { if (raf) return; raf = requestAnimationFrame(refreshChip); } // ---- soft refresh ---- var lastRoundCount = 0; function softRefresh() { // Lite: rescan assistants inside the active main only; no location.assign. closeMenu(); invalidateActiveMain(); var before = lastRoundCount; bound = null; pending = null; var rounds = collectRounds(); lastRoundCount = rounds.length; applyPageWidth(); scheduleChip(); if (currentRoot && !currentRoot.isConnected && panel && !panel.hidden) { toast('当前阅读消息已离开 DOM,请重新打开阅读器。', { durationMs: 4000 }); } var navItems = document.querySelectorAll( 'nav [data-testid*="conversation"], nav a[href*="#"], [class*="scroll"] button', ); var gaps = navItems.length > 0 && rounds.length + 2 < navItems.length && rounds.length < before; toast( gaps ? '部分消息可能尚未挂载。可先滚动再软刷新。' : '已软刷新消息导航(' + rounds.length + ' 条助手回复)。', { durationMs: gaps ? 8000 : 3500, actionLabel: gaps ? '仍可整页刷新' : null, onAction: gaps ? function () { location.reload(); } : null, }, ); } // ---- FAB + Lite menu ---- var fab = null; var fabWidthBtn = null; var fabRefreshBtn = null; var fabPrevBtn = null; var fabNextBtn = null; var menu = null; function mkBtn(text, title, onClick, cls) { var b = document.createElement('button'); b.type = 'button'; b.textContent = text; if (title) b.title = title; if (cls) b.className = cls; b.addEventListener('click', function (e) { e.stopPropagation(); onClick(e); }); return b; } function ensureFab() { if (fab) { if (!fab.isConnected) document.documentElement.appendChild(fab); return; } fab = document.createElement('div'); fab.id = NS + '-fab'; fab.className = NS + '-fab'; var reader = mkBtn('📖 阅读器', '在侧栏阅读当前回复(含大纲)', openReaderForCurrent, 'secondary'); fabWidthBtn = mkBtn('', '页面宽度:标准 → 较宽 → 铺满(会记住)', function () { setPageWidth(nextWidth(pageWidth)); }, 'secondary'); fabWidthBtn.setAttribute('data-role', 'width'); fabRefreshBtn = mkBtn('🔄 软刷新', '重扫已挂载消息,不整页刷新', softRefresh, 'secondary'); fabRefreshBtn.setAttribute('data-role', 'refresh'); var more = mkBtn('Lite ☰', 'MarkDone Lite 菜单', toggleMenu); more.setAttribute('data-role', 'menu'); fabPrevBtn = mkBtn('▲', '上一条助手回复(←)', function () { stepMessage(-1); }, 'secondary step'); fabPrevBtn.setAttribute('data-role', 'step-prev'); fabPrevBtn.setAttribute('aria-label', '上一条助手回复'); fabNextBtn = mkBtn('▼', '下一条助手回复(→)', function () { stepMessage(1); }, 'secondary step'); fabNextBtn.setAttribute('data-role', 'step-next'); fabNextBtn.setAttribute('aria-label', '下一条助手回复'); fab.appendChild(fabPrevBtn); fab.appendChild(fabNextBtn); fab.appendChild(reader); fab.appendChild(fabWidthBtn); fab.appendChild(fabRefreshBtn); fab.appendChild(more); document.documentElement.appendChild(fab); syncFab(); } function syncFab() { if (!fabWidthBtn) return; fabWidthBtn.textContent = '↔ 宽度·' + WIDTH_LABEL[pageWidth]; fabWidthBtn.setAttribute('aria-label', '页面宽度:' + WIDTH_LABEL[pageWidth]); if (fabRefreshBtn) fabRefreshBtn.hidden = !showRefreshFab; if (fabPrevBtn) fabPrevBtn.hidden = !stepNav; if (fabNextBtn) fabNextBtn.hidden = !stepNav; } function ensureMenu() { if (menu) { if (!menu.isConnected) document.documentElement.appendChild(menu); return menu; } menu = document.createElement('div'); menu.className = NS + '-menu'; menu.setAttribute('role', 'menu'); menu.hidden = true; menu.addEventListener('click', function (e) { e.stopPropagation(); }); document.documentElement.appendChild(menu); return menu; } function renderMenu() { ensureMenu(); menu.innerHTML = ''; menu.appendChild(mkBtn('📖 阅读器(当前回复)', '在侧栏打开视口内主读回复', openReaderForCurrent)); if (copyMdOn) { var cp = mkBtn('📋 复制当前回复 Markdown', '复制视口内主读回复(或阅读器里这条)的 Markdown', copyCurrentMarkdown); cp.setAttribute('data-role', 'copy-md'); menu.appendChild(cp); } if (stepNav) { var srow = document.createElement('div'); srow.className = NS + '-menu__row'; var slab = document.createElement('span'); slab.className = NS + '-menu__label'; slab.textContent = '消息步进'; srow.appendChild(slab); var sp = mkBtn('▲ 上一条', '上一条助手回复', function () { stepMessage(-1); }); sp.setAttribute('data-role', 'menu-step-prev'); var sn = mkBtn('▼ 下一条', '下一条助手回复', function () { stepMessage(1); }); sn.setAttribute('data-role', 'menu-step-next'); srow.appendChild(sp); srow.appendChild(sn); menu.appendChild(srow); } var pin = mkBtn( (outlinePinned ? '📌 大纲钉住:开' : '📍 大纲钉住:关'), '阅读器右侧大纲常显 / 悬停展开', function () { setOutlinePinned(!outlinePinned); }, ); pin.setAttribute('aria-checked', outlinePinned ? 'true' : 'false'); pin.setAttribute('role', 'menuitemcheckbox'); menu.appendChild(pin); var send = mkBtn( (sendCmdEnter ? '⌨ ' + SEND_KEY_LABEL + ' 发送:开(Enter 换行)' : '⌨ ' + SEND_KEY_LABEL + ' 发送:关'), 'ChatGPT 输入框:Enter 换行,' + SEND_KEY_LABEL + ' 发送;Shift+Enter 仍是换行', function () { setSendCmdEnter(!sendCmdEnter); }, ); send.setAttribute('data-role', 'send-toggle'); send.setAttribute('role', 'menuitemcheckbox'); send.setAttribute('aria-checked', sendCmdEnter ? 'true' : 'false'); menu.appendChild(send); var row = document.createElement('div'); row.className = NS + '-menu__row'; var lab = document.createElement('span'); lab.className = NS + '-menu__label'; lab.textContent = '↔ 宽度'; row.appendChild(lab); WIDTH_ORDER.forEach(function (m) { var b = mkBtn(WIDTH_LABEL[m], '页面宽度:' + WIDTH_LABEL[m], function () { setPageWidth(m); }); b.setAttribute('data-width', m); b.setAttribute('aria-pressed', pageWidth === m ? 'true' : 'false'); row.appendChild(b); }); menu.appendChild(row); menu.appendChild(mkBtn('🔄 软刷新导航', '重扫已挂载消息,不整页刷新', softRefresh)); var gear = mkBtn('⚙ 设置…', '打开 MarkDone Lite 设置面板', openSettings); gear.setAttribute('data-role', 'settings'); menu.appendChild(gear); menu.appendChild(document.createElement('hr')); var info = document.createElement('small'); var widthOk = hasWidthContainer(); var activeMain = getActiveMain(); info.textContent = 'Lite v' + VERSION + ' · 检测到 ' + assistantRoots().length + ' 条助手回复' + (activeMain ? ' · 已锁定当前页 main' : ' · 未找到 active main') + (widthOk ? ' · 宽度容器已命中' : ' · 未找到宽度容器') + ' · 宽度=' + WIDTH_LABEL[pageWidth] + ' · 发送键=' + (sendCmdEnter ? SEND_KEY_LABEL : '默认'); menu.appendChild(info); } function syncMenu() { if (menu && !menu.hidden) renderMenu(); } function toggleMenu() { ensureMenu(); if (menu.hidden) { renderMenu(); menu.hidden = false; } else { menu.hidden = true; } } function closeMenu() { if (menu) menu.hidden = true; } // ---- Cmd/Ctrl+Enter to send (Enter = newline) ---- // Own implementation of the same idea as the extension's ChatGPT input enhancement // (setting enterKeyNewline): Enter -> newline, Cmd/Ctrl+Enter -> send, Shift+Enter untouched. // One capture-phase listener on window => survives SPA nav / composer re-render. var COMPOSER_SELECTOR = '#prompt-textarea, form[data-type="unified-composer"] [contenteditable="true"], form[data-type="unified-composer"] textarea'; var SEND_BTN_SELECTOR = [ 'button[data-testid="send-button"]', 'button[data-testid="composer-submit-button"]', 'button[aria-label="Send prompt"]', 'button[aria-label="发送提示"]', 'button[aria-label="Send message"]', ].join(','); var imeComposing = false; var imeEndedAt = 0; var synthetic = false; // true while we re-dispatch a key to the page's own handler function composerFromTarget(t) { if (!(t instanceof Element)) return null; var el = t.closest(COMPOSER_SELECTOR); if (el) return el; return null; } function isImeKey(e) { return e.isComposing || e.keyCode === 229 || imeComposing || Date.now() - imeEndedAt < 30; } function fireKey(target, type, init) { var ev = new KeyboardEvent(type, Object.assign({ key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true }, init)); target.dispatchEvent(ev); return ev; } function insertNewline(target) { if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) { var st = target.selectionStart == null ? target.value.length : target.selectionStart; var en = target.selectionEnd == null ? st : target.selectionEnd; target.value = target.value.slice(0, st) + '\n' + target.value.slice(en); target.selectionStart = target.selectionEnd = st + 1; target.dispatchEvent(new Event('input', { bubbles: true })); return; } // ProseMirror: its own Shift+Enter binding inserts a hard break. If nothing handled // the synthetic key, fall back to the browser's native line-break command. synthetic = true; try { var ev = fireKey(target, 'keydown', { shiftKey: true }); if (!ev.defaultPrevented) { var ok = false; try { ok = document.execCommand('insertLineBreak'); } catch (_) { /* ignore */ } if (!ok) { try { document.execCommand('insertParagraph'); } catch (_) { /* ignore */ } } } fireKey(target, 'keyup', { shiftKey: true }); } finally { synthetic = false; } } function triggerSend(target) { var form = target.closest('form') || document; var btn = form.querySelector(SEND_BTN_SELECTOR); if (btn && !btn.disabled && btn.getAttribute('aria-disabled') !== 'true') { btn.click(); return; } // No (enabled) send button found: replay a plain Enter into the page's own handler. synthetic = true; try { fireKey(target, 'keydown', {}); fireKey(target, 'keyup', {}); } finally { synthetic = false; } } function onSendKeydown(e) { if (!sendCmdEnter || synthetic || e.defaultPrevented) return; if (e.key !== 'Enter') return; if (e.shiftKey || e.altKey) return; // Shift+Enter: native newline; Alt: leave alone if (isImeKey(e)) return; // never touch IME confirm-Enter var target = composerFromTarget(e.target); if (!target) return; var mod = e.metaKey || e.ctrlKey; e.preventDefault(); e.stopImmediatePropagation(); if (mod) { if (e.repeat) return; triggerSend(target); } else { insertNewline(target); } } function setSendCmdEnter(v, silent) { sendCmdEnter = !!v; storeSet(SEND_KEY, sendCmdEnter); syncMenu(); syncSettings(); if (!silent) { toast( sendCmdEnter ? '已开启:' + SEND_KEY_LABEL + ' 发送,Enter 换行(仅 ChatGPT 输入框)' : '已关闭:恢复 ChatGPT 默认(Enter 发送)', { durationMs: 2600 }, ); } } function setShowRefreshFab(v) { showRefreshFab = !!v; storeSet(REFRESH_FAB_KEY, showRefreshFab); syncFab(); syncSettings(); } function setShowChip(v) { showChip = !!v; storeSet(CHIP_KEY, showChip); scheduleChip(); syncSettings(); } function setReaderWidth(mode) { readerWidth = normWidth(mode); storeSet(READER_WIDTH_KEY, readerWidth); syncReaderWidth(); syncSettings(); } function resetSettings() { setSendCmdEnter(false, true); setStepNav(true); setStepKeys(true); setShowWords(true); setCopyMd(true); setShowRefreshFab(false); setShowChip(true); setOutlinePinned(false, true); setPageWidth('normal', true); setReaderWidth('normal'); toast('已恢复 Lite 默认设置', { durationMs: 2000 }); } // ---- settings panel ---- var settingsEl = null; function ensureSettings() { if (settingsEl) { if (!settingsEl.isConnected) document.documentElement.appendChild(settingsEl); return settingsEl; } settingsEl = document.createElement('div'); settingsEl.className = NS + '-settings'; settingsEl.id = NS + '-settings'; settingsEl.hidden = true; settingsEl.setAttribute('role', 'dialog'); settingsEl.setAttribute('aria-label', 'MarkDone Lite 设置'); settingsEl.addEventListener('click', function (e) { if (e.target === settingsEl) closeSettings(); }); document.documentElement.appendChild(settingsEl); return settingsEl; } function settingRow(label, hint, control) { var row = document.createElement('div'); row.className = NS + '-settings__row'; var txt = document.createElement('div'); txt.className = NS + '-settings__txt'; txt.appendChild(document.createTextNode(label)); if (hint) { var sm = document.createElement('small'); sm.textContent = hint; txt.appendChild(sm); } row.appendChild(txt); row.appendChild(control); return row; } function toggleCtl(on, key, onChange) { var b = document.createElement('button'); b.type = 'button'; b.setAttribute('role', 'switch'); b.setAttribute('data-setting', key); b.setAttribute('aria-checked', on ? 'true' : 'false'); b.textContent = on ? '开' : '关'; b.addEventListener('click', function (e) { e.stopPropagation(); onChange(!on); }); return b; } function segCtl(current, key, onPick) { var wrap = document.createElement('div'); wrap.className = NS + '-settings__seg'; WIDTH_ORDER.forEach(function (m) { var b = document.createElement('button'); b.type = 'button'; b.textContent = WIDTH_LABEL[m]; b.setAttribute('data-setting', key); b.setAttribute('data-value', m); b.setAttribute('aria-pressed', current === m ? 'true' : 'false'); b.addEventListener('click', function (e) { e.stopPropagation(); onPick(m); }); wrap.appendChild(b); }); return wrap; } function renderSettings() { var el = ensureSettings(); el.innerHTML = ''; var card = document.createElement('div'); card.className = NS + '-settings__card'; var head = document.createElement('div'); head.className = NS + '-settings__head'; var title = document.createElement('strong'); title.textContent = 'MarkDone Lite 设置'; var close = document.createElement('button'); close.type = 'button'; close.textContent = '关闭'; close.addEventListener('click', closeSettings); head.appendChild(title); head.appendChild(close); card.appendChild(head); card.appendChild(settingRow(SEND_KEY_LABEL + ' 发送', '开启后:Enter 换行,' + SEND_KEY_LABEL + ' 发送;Shift+Enter 仍是换行;输入法选字不受影响。默认关。', toggleCtl(sendCmdEnter, 'sendCmdEnter', function (v) { setSendCmdEnter(v); }))); card.appendChild(settingRow('大纲钉住', '阅读器右侧大纲常显(关=悬停展开)。', toggleCtl(outlinePinned, 'outlinePinned', function (v) { setOutlinePinned(v); }))); card.appendChild(settingRow('默认页面宽度', '对话主栏宽度,会记住。', segCtl(pageWidth, 'pageWidth', function (m) { setPageWidth(m); }))); card.appendChild(settingRow('阅读器宽度', '侧栏阅读器自身宽度。', segCtl(readerWidth, 'readerWidth', function (m) { setReaderWidth(m); }))); card.appendChild(settingRow('消息步进', '右下角 ▲▼ 按钮:跳到上一条/下一条助手回复。', toggleCtl(stepNav, 'stepNav', function (v) { setStepNav(v); }))); card.appendChild(settingRow('←/→ 键步进', '不在输入框里打字、没选中文字时,← 上一条、→ 下一条。需先开「消息步进」。', toggleCtl(stepKeys, 'stepKeys', function (v) { setStepKeys(v); }))); card.appendChild(settingRow('字数统计', '阅读器小标签和侧栏顶部显示本条回复字数(中日文按字、英文按词,不含代码块和公式)。', toggleCtl(showWords, 'showWords', function (v) { setShowWords(v); }))); card.appendChild(settingRow('复制 Markdown', '小标签上的「📋 MD」、侧栏顶部「复制 MD」、菜单项;保留代码围栏/语言、表格、列表、标题、链接。', toggleCtl(copyMdOn, 'copyMd', function (v) { setCopyMd(v); }))); card.appendChild(settingRow('软刷新按钮', '在右下角显示「🔄 软刷新」;关=只在 Lite ☰ 菜单里。', toggleCtl(showRefreshFab, 'showRefreshFab', function (v) { setShowRefreshFab(v); }))); card.appendChild(settingRow('跟随阅读小标签', '滚动时跟在当前回复旁的「📖 阅读器」小标签;关=只用右下按钮。', toggleCtl(showChip, 'showChip', function (v) { setShowChip(v); }))); var foot = document.createElement('div'); foot.className = NS + '-settings__foot'; var small = document.createElement('small'); small.textContent = 'Lite v' + VERSION; var reset = document.createElement('button'); reset.type = 'button'; reset.textContent = '恢复默认'; reset.addEventListener('click', resetSettings); foot.appendChild(small); foot.appendChild(reset); card.appendChild(foot); el.appendChild(card); } function openSettings() { closeMenu(); renderSettings(); settingsEl.hidden = false; } function closeSettings() { if (settingsEl) settingsEl.hidden = true; } function syncSettings() { if (settingsEl && !settingsEl.hidden) renderSettings(); } // ---- watchdog: survive SPA navigation / React re-render ---- var lastHref = location.href; function ensureAll() { ensureStyle(); ensureChip(); ensureFab(); if (menu && !menu.isConnected) document.documentElement.appendChild(menu); if (panel && !panel.isConnected) document.documentElement.appendChild(panel); if (settingsEl && !settingsEl.isConnected) document.documentElement.appendChild(settingsEl); applyPageWidth(); if (location.href !== lastHref) { lastHref = location.href; invalidateActiveMain(); bound = null; pending = null; closeMenu(); if (panel && !panel.hidden && currentRoot && !currentRoot.isConnected) panel.hidden = true; } } // ---- boot ---- function boot() { ensureAll(); lastRoundCount = collectRounds().length; scheduleChip(); window.addEventListener('scroll', scheduleChip, { capture: true, passive: true }); document.addEventListener('scroll', scheduleChip, { capture: true, passive: true }); window.addEventListener('resize', scheduleChip, { passive: true }); window.addEventListener('popstate', function () { setTimeout(ensureAll, 0); scheduleChip(); }); document.addEventListener('click', closeMenu); window.addEventListener('keydown', onSendKeydown, true); window.addEventListener('keydown', onStepKeydown); window.addEventListener('compositionstart', function () { imeComposing = true; }, true); window.addEventListener('compositionend', function () { imeComposing = false; imeEndedAt = Date.now(); }, true); try { if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('MarkDone Lite 设置', openSettings); GM_registerMenuCommand('MarkDone Lite:' + SEND_KEY_LABEL + ' 发送 开/关', function () { setSendCmdEnter(!sendCmdEnter); }); } } catch (_) { /* ignore */ } document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; if (settingsEl && !settingsEl.hidden) { closeSettings(); return; } if (menu && !menu.hidden) closeMenu(); else if (panel && !panel.hidden) { panel.hidden = true; scheduleChip(); } }); var moTimer = 0; var mo = new MutationObserver(function (mutations) { var surfaceChanged = false; for (var i = 0; i < mutations.length; i++) { var m = mutations[i]; if (m.type === 'attributes' && m.attributeName === ACTIVE_PAGE_ATTR) { surfaceChanged = true; break; } } if (surfaceChanged) invalidateActiveMain(); scheduleChip(); if (moTimer) return; moTimer = setTimeout(function () { moTimer = 0; ensureAll(); }, 250); }); mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: [ACTIVE_PAGE_ATTR], }); setInterval(ensureAll, 1500); var am = getActiveMain(); console.info( '[MarkDone Lite]', VERSION, 'booted;', assistantRoots().length, 'assistant nodes; width=' + pageWidth + ';', 'activeMain=', am ? (am.id || am.tagName) : 'none', 'sendCmdEnter=' + sendCmdEnter, ); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();