feat: HTML/SVG preview in code blocks; fix scroll jump on history render
- Add Preview/Source toggle button for html/svg code blocks (iframe sandbox) - Fix viewport jumping when prepending history batches (scrollTop compensation) - Update CHANGELOG for v1.2.6
This commit is contained in:
@@ -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/` 下对应的原生会话历史,遍历所有项目目录确保完整清除。
|
||||
- 新增删除确认弹窗,支持「确认且不再提示」选项,风格与主界面一致。
|
||||
|
||||
@@ -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 `<div class="code-block-wrapper">
|
||||
const canPreview = PREVIEW_LANGS.has(lang);
|
||||
const previewBtn = canPreview
|
||||
? `<button class="code-preview-btn" onclick="ccTogglePreview(this)">Preview</button>`
|
||||
: '';
|
||||
const previewPane = canPreview
|
||||
? `<div class="code-preview-pane"><iframe class="code-preview-iframe" sandbox="allow-scripts" loading="lazy"></iframe></div>`
|
||||
: '';
|
||||
const cid = canPreview ? (++_previewCodeId) : 0;
|
||||
if (canPreview) _previewCodeMap.set(cid, code);
|
||||
return `<div class="code-block-wrapper${canPreview ? ' has-preview' : ''}"${canPreview ? ` data-cid="${cid}"` : ''}>
|
||||
<div class="code-block-header">
|
||||
<span>${escapeHtml(lang)}</span>
|
||||
<button class="code-copy-btn" onclick="ccCopyCode(this)">Copy</button>
|
||||
<div class="code-block-actions">${previewBtn}<button class="code-copy-btn" onclick="ccCopyCode(this)">Copy</button></div>
|
||||
</div>
|
||||
<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
|
||||
${previewPane}<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
|
||||
</div>`;
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user