feat(chat): show_html 卡片工具栏改为 ⋯ 菜单并新增全屏预览

This commit is contained in:
JOJO 2026-08-05 01:03:03 +08:00
parent 79b916f44c
commit 2db77fe663
2 changed files with 501 additions and 73 deletions

View File

@ -505,42 +505,309 @@ function escapeHtml(input: string) {
.replace(/'/g, ''');
}
function bindShowHtmlRefreshControl(wrapper: HTMLElement, onRefresh: () => void) {
/**
* show_html
* - js="on" CSP/ srcdoc
* - js="off" sanitize HTML srcdoc
*/
interface ShowHtmlFullscreenPayload {
srcdoc: string;
allowScripts: boolean;
}
interface ShowHtmlCardControls {
onRefresh: () => void;
getFullscreenPayload: () => ShowHtmlFullscreenPayload | null;
}
const SHOW_HTML_CONTROLS_KEY = '__showHtmlCardControls';
function getShowHtmlCardControls(wrapper: HTMLElement): ShowHtmlCardControls | null {
return ((wrapper as any)[SHOW_HTML_CONTROLS_KEY] as ShowHtmlCardControls | undefined) || null;
}
function triggerShowHtmlCardRefresh(wrapper: HTMLElement, btn: HTMLElement | null) {
const controls = getShowHtmlCardControls(wrapper);
if (!controls) return;
markShowTagDrawingActive(1200);
if (btn) {
btn.classList.remove('is-refreshing');
// 触发重放动画
void btn.offsetWidth;
btn.classList.add('is-refreshing');
window.setTimeout(() => btn.classList.remove('is-refreshing'), 720);
}
try {
controls.onRefresh();
} catch (error) {
debugShowHtmlLog('refresh:error', {
message: error instanceof Error ? error.message : String(error || '')
});
}
}
// ===== 卡片 ⋯ 菜单(全局 fixed 单例,同一时间只存在一个) =====
let showHtmlCardMenuEl: HTMLElement | null = null;
let showHtmlCardMenuAnchor: HTMLElement | null = null;
let showHtmlCardMenuEventsBound = false;
function closeShowHtmlCardMenu() {
if (showHtmlCardMenuEl) {
showHtmlCardMenuEl.remove();
showHtmlCardMenuEl = null;
showHtmlCardMenuAnchor = null;
}
}
function bindShowHtmlCardMenuGlobalEvents() {
if (showHtmlCardMenuEventsBound || typeof document === 'undefined') return;
showHtmlCardMenuEventsBound = true;
document.addEventListener(
'click',
(event) => {
if (!showHtmlCardMenuEl) return;
const target = event.target as Node | null;
if (target && showHtmlCardMenuEl.contains(target)) return;
// 锚点按钮自身走 toggle 逻辑,不在全局监听里关闭
if (target && showHtmlCardMenuAnchor?.contains(target)) return;
closeShowHtmlCardMenu();
},
true
);
document.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape' && showHtmlCardMenuEl) {
event.stopPropagation();
closeShowHtmlCardMenu();
}
},
true
);
window.addEventListener('resize', closeShowHtmlCardMenu);
// 聊天区滚动时菜单位置即失效直接关闭capture 以捕获任意滚动容器)
document.addEventListener(
'scroll',
() => {
if (showHtmlCardMenuEl) closeShowHtmlCardMenu();
},
true
);
}
function buildShowHtmlCardMenuItem(label: string): HTMLButtonElement {
const item = document.createElement('button');
item.type = 'button';
item.className = 'show-html-card-menu__item';
item.textContent = label;
return item;
}
function openShowHtmlCardMenu(anchor: HTMLElement, wrapper: HTMLElement) {
bindShowHtmlCardMenuGlobalEvents();
if (showHtmlCardMenuEl && showHtmlCardMenuAnchor === anchor) {
closeShowHtmlCardMenu();
return;
}
closeShowHtmlCardMenu();
const menu = document.createElement('div');
menu.className = 'show-html-card-menu';
menu.setAttribute('role', 'menu');
const refreshItem = buildShowHtmlCardMenuItem('刷新');
refreshItem.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
closeShowHtmlCardMenu();
triggerShowHtmlCardRefresh(wrapper, anchor);
};
const fullscreenItem = buildShowHtmlCardMenuItem('全屏');
fullscreenItem.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const controls = getShowHtmlCardControls(wrapper);
closeShowHtmlCardMenu();
const payload = controls?.getFullscreenPayload?.();
if (!payload || !payload.srcdoc) return;
openShowHtmlFullscreen(payload);
};
menu.appendChild(refreshItem);
menu.appendChild(fullscreenItem);
document.body.appendChild(menu);
// 定位:按钮下方、右对齐,视口边缘留 8px 安全距离
const rect = anchor.getBoundingClientRect();
const menuWidth = menu.offsetWidth;
let left = Math.round(rect.right - menuWidth);
left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
menu.style.left = `${left}px`;
menu.style.top = `${Math.round(rect.bottom + 6)}px`;
showHtmlCardMenuEl = menu;
showHtmlCardMenuAnchor = anchor;
}
// ===== show_html 全屏预览(应用内 overlay复用沙箱 srcdoc不走路由 =====
interface ShowHtmlFullscreenState {
root: HTMLElement;
iframe: HTMLIFrameElement;
payload: ShowHtmlFullscreenPayload | null;
}
const SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC =
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
let showHtmlFullscreenState: ShowHtmlFullscreenState | null = null;
let showHtmlFullscreenEventsBound = false;
function isShowHtmlFullscreenOpen(): boolean {
return !!showHtmlFullscreenState?.root.classList.contains('is-open');
}
function bindShowHtmlFullscreenGlobalEvents() {
if (showHtmlFullscreenEventsBound || typeof document === 'undefined') return;
showHtmlFullscreenEventsBound = true;
// 桌面端 Esc 退出;移动端没有 Esc依赖顶栏 ✕ 按钮(触屏主路径)
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !isShowHtmlFullscreenOpen()) return;
event.stopPropagation();
closeShowHtmlFullscreen();
});
}
function ensureShowHtmlFullscreenDom(): ShowHtmlFullscreenState {
if (showHtmlFullscreenState) return showHtmlFullscreenState;
bindShowHtmlFullscreenGlobalEvents();
const root = document.createElement('div');
root.className = 'show-html-fullscreen';
root.setAttribute('role', 'dialog');
root.setAttribute('aria-modal', 'true');
root.setAttribute('aria-label', '卡片全屏预览');
const bar = document.createElement('div');
bar.className = 'show-html-fullscreen__bar';
const title = document.createElement('span');
title.className = 'show-html-fullscreen__title';
title.textContent = '全屏预览';
const actions = document.createElement('div');
actions.className = 'show-html-fullscreen__actions';
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'show-html-fullscreen__btn';
refreshBtn.title = '刷新';
refreshBtn.setAttribute('aria-label', '刷新');
refreshBtn.textContent = '↻';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'show-html-fullscreen__btn show-html-fullscreen__btn--close';
closeBtn.title = '关闭Esc';
closeBtn.setAttribute('aria-label', '关闭全屏预览');
closeBtn.textContent = '✕';
actions.appendChild(refreshBtn);
actions.appendChild(closeBtn);
bar.appendChild(title);
bar.appendChild(actions);
const body = document.createElement('div');
body.className = 'show-html-fullscreen__body';
const iframe = document.createElement('iframe');
iframe.className = 'show-html-fullscreen__iframe';
iframe.setAttribute('referrerpolicy', 'no-referrer');
// 与内联卡片一致overlay 用 CSS fixed 占满视口,不需要浏览器 Fullscreen API
iframe.setAttribute('allow', "fullscreen 'none'");
body.appendChild(iframe);
root.appendChild(bar);
root.appendChild(body);
refreshBtn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const state = showHtmlFullscreenState;
if (!state?.payload) return;
// 与内联卡片刷新同策略:先挂空文档再重挂,强制 iframe 内页面重载
const keep = state.payload.srcdoc;
state.iframe.srcdoc = SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC;
requestAnimationFrame(() => {
if (showHtmlFullscreenState?.payload) {
showHtmlFullscreenState.iframe.srcdoc = keep;
}
});
debugShowHtmlLog('fullscreen:refresh', { srcdocLength: keep.length });
};
closeBtn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
closeShowHtmlFullscreen();
};
document.body.appendChild(root);
showHtmlFullscreenState = { root, iframe, payload: null };
return showHtmlFullscreenState;
}
function openShowHtmlFullscreen(payload: ShowHtmlFullscreenPayload) {
if (typeof document === 'undefined') return;
const state = ensureShowHtmlFullscreenDom();
state.payload = payload;
// js=off 卡片内容已 sanitize全屏时连脚本执行权也不给语义与卡片内一致
state.iframe.setAttribute('sandbox', payload.allowScripts ? 'allow-scripts' : '');
state.iframe.srcdoc = payload.srcdoc;
state.root.classList.add('is-open');
document.documentElement.classList.add('show-html-fullscreen-open');
debugShowHtmlLog('fullscreen:open', {
allowScripts: payload.allowScripts,
srcdocLength: payload.srcdoc.length
});
}
function closeShowHtmlFullscreen() {
const state = showHtmlFullscreenState;
if (!state || !isShowHtmlFullscreenOpen()) return;
state.root.classList.remove('is-open');
document.documentElement.classList.remove('show-html-fullscreen-open');
state.payload = null;
// 清空 srcdoc停掉 iframe 内脚本/定时器并释放内存
state.iframe.srcdoc = SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC;
debugShowHtmlLog('fullscreen:close', {});
}
/**
* show_html /
* wrapper
*/
function bindShowHtmlCardControls(wrapper: HTMLElement, controls: ShowHtmlCardControls) {
(wrapper as any)[SHOW_HTML_CONTROLS_KEY] = controls;
let toolbar = wrapper.querySelector(':scope > .chat-inline-card__toolbar') as HTMLElement | null;
if (!toolbar) {
toolbar = document.createElement('div');
toolbar.className = 'chat-inline-card__toolbar';
wrapper.appendChild(toolbar);
}
let refreshBtn = toolbar.querySelector(
':scope > .chat-inline-card__refresh-btn'
let menuBtn = toolbar.querySelector(
':scope > .chat-inline-card__menu-btn'
) as HTMLButtonElement | null;
if (!refreshBtn) {
refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'chat-inline-card__refresh-btn';
refreshBtn.title = '刷新卡片';
refreshBtn.setAttribute('aria-label', '刷新卡片');
refreshBtn.textContent = '↻';
toolbar.appendChild(refreshBtn);
if (!menuBtn) {
menuBtn = document.createElement('button');
menuBtn.type = 'button';
menuBtn.className = 'chat-inline-card__menu-btn';
menuBtn.title = '卡片操作';
menuBtn.setAttribute('aria-label', '卡片操作');
menuBtn.setAttribute('aria-haspopup', 'menu');
menuBtn.textContent = '⋯';
toolbar.appendChild(menuBtn);
}
refreshBtn.onclick = (event) => {
menuBtn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
markShowTagDrawingActive(1200);
refreshBtn!.classList.remove('is-refreshing');
// 触发重放动画
void refreshBtn!.offsetWidth;
refreshBtn!.classList.add('is-refreshing');
try {
onRefresh();
} catch (error) {
debugShowHtmlLog('refresh:error', {
message: error instanceof Error ? error.message : String(error || '')
});
} finally {
window.setTimeout(() => refreshBtn?.classList.remove('is-refreshing'), 720);
}
openShowHtmlCardMenu(menuBtn!, wrapper);
};
}
@ -787,21 +1054,29 @@ function renderShowImages(root: ParentNode | null = document) {
};
showHtmlJsIframeRenderByPath.set(pathKey, persistent);
}
bindShowHtmlRefreshControl(persistent.wrapper, () => {
// 强制重新挂载同一份 srcdoc触发 iframe 内页面重载
const keep = persistent?.srcdoc || srcdoc;
persistent.iframe.srcdoc =
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
requestAnimationFrame(() => {
if (persistent?.iframe) {
persistent.iframe.srcdoc = keep;
}
});
debugShowHtmlLog('refresh:iframe', {
pathKey,
ratioKey: persistent?.ratioKey || ratioKey,
srcdocLength: keep.length
});
bindShowHtmlCardControls(persistent.wrapper, {
onRefresh: () => {
// 强制重新挂载同一份 srcdoc触发 iframe 内页面重载
const keep = persistent?.srcdoc || srcdoc;
persistent.iframe.srcdoc =
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
requestAnimationFrame(() => {
if (persistent?.iframe) {
persistent.iframe.srcdoc = keep;
}
});
debugShowHtmlLog('refresh:iframe', {
pathKey,
ratioKey: persistent?.ratioKey || ratioKey,
srcdocLength: keep.length
});
},
// 全屏直接复用当前 srcdoc已含 CSP meta 与守卫脚本),沙箱保持 allow-scripts
getFullscreenPayload: () => {
const current = persistent?.srcdoc || '';
if (!current) return null;
return { srcdoc: current, allowScripts: true };
}
});
const needsUpdate =
persistent.encoded !== effectiveEncoded ||
@ -933,19 +1208,27 @@ function renderShowImages(root: ParentNode | null = document) {
};
showHtmlPersistentRenderByPath.set(pathKey, persistent);
}
bindShowHtmlRefreshControl(persistent.wrapper, () => {
const keep = persistent?.safeHtml || '';
persistent.root.innerHTML = '';
requestAnimationFrame(() => {
if (persistent?.root) {
persistent.root.innerHTML = keep;
}
});
debugShowHtmlLog('refresh:shadow-root', {
pathKey,
ratioKey: persistent?.ratioKey || ratioKey,
safeLength: keep.length
});
bindShowHtmlCardControls(persistent.wrapper, {
onRefresh: () => {
const keep = persistent?.safeHtml || '';
persistent.root.innerHTML = '';
requestAnimationFrame(() => {
if (persistent?.root) {
persistent.root.innerHTML = keep;
}
});
debugShowHtmlLog('refresh:shadow-root', {
pathKey,
ratioKey: persistent?.ratioKey || ratioKey,
safeLength: keep.length
});
},
// js=off 内容已 sanitize全屏走禁脚本沙箱 iframe渲染效果与 Shadow DOM 一致
getFullscreenPayload: () => {
const current = persistent?.safeHtml || '';
if (!current.trim()) return null;
return { srcdoc: buildShowHtmlIframeSrcdoc(current), allowScripts: false };
}
});
const needsUpdate =
@ -1369,6 +1652,9 @@ export function teardownShowImageObserver() {
showHtmlJsPendingRenderByPath.clear();
showHtmlJsIframeRenderByPath.clear();
showTagObservedContainer = null;
// 菜单/全屏层是全局单例,卡片 Map 清空后其引用的 wrapper 已卸载,必须一并关闭
closeShowHtmlCardMenu();
closeShowHtmlFullscreen();
}
function updateViewportHeightVar() {

View File

@ -1758,42 +1758,42 @@ body[data-theme='dark'] .more-icon {
pointer-events: none;
}
.chat-inline-card__refresh-btn {
/* ⋯ 菜单按钮纯文字风格无边框无背景hover 仅变色;保留 26px 点击热区 */
.chat-inline-card__menu-btn {
pointer-events: auto;
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--border-default);
background: color-mix(in srgb, var(--surface-card) 78%, transparent);
border: 0;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-size: 16px;
line-height: 1;
/* ⋯ 字形视觉重心偏下,上抬补偵使其看起来居中 */
padding-bottom: 5px;
cursor: pointer;
transition:
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease;
transition: color 0.18s ease;
}
.chat-inline-card__refresh-btn:hover {
.chat-inline-card__menu-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
background: color-mix(in srgb, var(--highlight) 72%, transparent);
}
.chat-inline-card__refresh-btn.is-refreshing {
animation: chat-inline-refresh-spin 0.6s linear 1;
.chat-inline-card__menu-btn.is-refreshing {
animation: chat-inline-menu-pulse 0.6s ease 1;
}
@keyframes chat-inline-refresh-spin {
from {
transform: rotate(0deg);
@keyframes chat-inline-menu-pulse {
0% {
opacity: 1;
}
to {
transform: rotate(360deg);
40% {
opacity: 0.35;
}
100% {
opacity: 1;
}
}
@ -1818,6 +1818,148 @@ body[data-theme='dark'] .more-icon {
background: transparent;
}
/* ---------------- show_html 卡片 ⋯ 菜单(全局 fixed 单例) ---------------- */
.show-html-card-menu {
position: fixed;
z-index: 3050;
min-width: 112px;
padding: 4px;
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: 8px;
box-shadow: var(--shadow-card);
animation: show-html-card-menu-in 0.16s ease-out;
}
@keyframes show-html-card-menu-in {
from {
opacity: 0;
transform: translateY(-3px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.show-html-card-menu__item {
display: flex;
align-items: center;
width: 100%;
height: 32px;
padding: 0 12px;
background: none;
border: none;
border-radius: 5px;
font-size: 13px;
text-align: left;
color: var(--text-primary);
cursor: pointer;
transition: background 0.12s ease;
}
.show-html-card-menu__item:hover {
background: var(--surface-soft);
}
/* ---------------- show_html 全屏预览 overlay ---------------- */
/* 打开期间锁定底层页面滚动(聊天区滚动容器在 body 下) */
html.show-html-fullscreen-open,
html.show-html-fullscreen-open body {
overflow: hidden;
}
.show-html-fullscreen {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
/* 与聊天区同一套视口高度变量,规避移动端 100vh 地址栏问题 */
height: var(--app-viewport, 100vh);
z-index: 3000;
background: var(--surface-base);
flex-direction: column;
}
.show-html-fullscreen.is-open {
display: flex;
}
.show-html-fullscreen__bar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
/* 手机刘海/状态栏安全区;移动端无法 Esc关闭按钮必须永远可见可达 */
padding: calc(8px + env(safe-area-inset-top, 0px))
calc(10px + env(safe-area-inset-right, 0px)) 8px
calc(16px + env(safe-area-inset-left, 0px));
border-bottom: 1px solid var(--border-default);
}
.show-html-fullscreen__title {
font-size: 13px;
color: var(--text-secondary);
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.show-html-fullscreen__actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
/* 与卡片 ⋯ 按钮一致的纯文字风格无边框无背景hover 仅变色 */
.show-html-fullscreen__btn {
width: 32px;
height: 32px;
border: 0;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: color 0.18s ease;
}
.show-html-fullscreen__btn:hover {
color: var(--text-primary);
}
.show-html-fullscreen__body {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
.show-html-fullscreen__iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
/* 移动端加大热区,保证触屏可可靠点中关闭按钮 */
@media (max-width: 768px) {
.show-html-fullscreen__btn {
width: 36px;
height: 36px;
font-size: 16px;
}
}
/* 兼容旧类名(可逐步移除) */
.chat-inline-image__error {
@extend .chat-inline-card__error;