diff --git a/CHANGELOG.md b/CHANGELOG.md index 800d290..734e4af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - 修复 `~/.claude/settings.json` 写入竞争问题:改为原子写入(先写临时文件再 rename),避免 Claude 子进程读到写了一半的文件导致随机 401 认证失败。 - 修复 `ANTHROPIC_REASONING_MODEL` 被误删问题:补充到 settings.json 白名单,保留该字段不被覆盖。 - 移动端自定义滚动条优化:加宽滑块热区(18px),滚动时自动显示滑块,停止后 1.2 秒淡出,修复 hover 粘滞导致半透明滑块残留问题。 + - 修复历史消息分批渲染时 prepend 导致的视口跳动问题:通过补偿 scrollTop 保持可见区域稳定。 + - 新增 HTML/SVG 代码块实时预览:代码块右上角新增 Preview 按钮,点击在 iframe 中渲染效果,可切换回 Source 查看代码。 - 修复删除会话时同步删除 `~/.claude/projects/` 下对应的原生会话历史,遍历所有项目目录确保完整清除。 - 新增删除确认弹窗,支持「确认且不再提示」选项,风格与主界面一致。 diff --git a/public/app.js b/public/app.js index a7d5804..089b3cf 100644 --- a/public/app.js +++ b/public/app.js @@ -80,9 +80,13 @@ window.addEventListener('orientationchange', () => setTimeout(setVH, 100)); // --- marked config --- + const PREVIEW_LANGS = new Set(['html', 'svg']); + const _previewCodeMap = new Map(); + let _previewCodeId = 0; + const renderer = new marked.Renderer(); renderer.code = function (code, language) { - const lang = language || 'plaintext'; + const lang = (language || 'plaintext').toLowerCase(); let highlighted; try { if (hljs.getLanguage(lang)) { @@ -93,24 +97,53 @@ } catch { highlighted = escapeHtml(code); } - return `
+ const canPreview = PREVIEW_LANGS.has(lang); + const previewBtn = canPreview + ? `` + : ''; + const previewPane = canPreview + ? `
` + : ''; + const cid = canPreview ? (++_previewCodeId) : 0; + if (canPreview) _previewCodeMap.set(cid, code); + return `
${escapeHtml(lang)} - +
${previewBtn}
-
${highlighted}
+ ${previewPane}
${highlighted}
`; }; marked.setOptions({ renderer, breaks: true, gfm: true }); window.ccCopyCode = function (btn) { - const code = btn.closest('.code-block-wrapper').querySelector('code').textContent; + const wrapper = btn.closest('.code-block-wrapper'); + const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0; + const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code').textContent; navigator.clipboard.writeText(code).then(() => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); }); }; + window.ccTogglePreview = function (btn) { + const wrapper = btn.closest('.code-block-wrapper'); + const inPreview = wrapper.classList.contains('preview-mode'); + if (inPreview) { + wrapper.classList.remove('preview-mode'); + btn.textContent = 'Preview'; + } else { + const iframe = wrapper.querySelector('.code-preview-iframe'); + if (iframe && !iframe.dataset.loaded) { + const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0; + iframe.srcdoc = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : ''; + iframe.dataset.loaded = '1'; + } + wrapper.classList.add('preview-mode'); + btn.textContent = 'Source'; + } + }; + // --- WebSocket --- function connect() { if (ws && ws.readyState <= 1) return; @@ -466,15 +499,20 @@ scrollToBottom(); // Render remaining batches asynchronously, prepending each + // Use scrollHeight delta to keep current view position stable after prepend let delay = 0; for (let b = 1; b < batches.length; b++) { const [start, end] = batches[b]; delay += 16; setTimeout(() => { if (renderEpoch !== epoch) return; // session switched, abort stale render + const prevHeight = messagesDiv.scrollHeight; + const prevScrollTop = messagesDiv.scrollTop; const frag = document.createDocumentFragment(); for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i])); messagesDiv.insertBefore(frag, messagesDiv.firstChild); + // Compensate scrollTop so visible area stays unchanged + messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); updateScrollbar(); }, delay); } diff --git a/public/style.css b/public/style.css index c72beab..ca84a8a 100644 --- a/public/style.css +++ b/public/style.css @@ -568,7 +568,12 @@ body { font-size: 12px; color: #999; } -.code-copy-btn { +.code-block-actions { + display: flex; + gap: 4px; + align-items: center; +} +.code-copy-btn, .code-preview-btn { background: none; border: none; color: #999; @@ -577,7 +582,8 @@ body { padding: 2px 8px; border-radius: 4px; } -.code-copy-btn:hover { color: #fff; background: #444; } +.code-copy-btn:hover, .code-preview-btn:hover { color: #fff; background: #444; } +.code-preview-btn { border: 1px solid #555; } .code-block-wrapper pre { margin: 0; padding: 12px; @@ -592,6 +598,21 @@ body { line-height: 1.5; white-space: pre; } +/* HTML/SVG preview pane */ +.code-preview-pane { + display: none; + background: #fff; + border-top: 1px solid var(--border-color); +} +.code-preview-iframe { + width: 100%; + min-height: 120px; + max-height: 600px; + border: none; + display: block; +} +.code-block-wrapper.preview-mode .code-preview-pane { display: block; } +.code-block-wrapper.preview-mode pre { display: none; } /* Tool calls */ .tool-call {