From 2db77fe663c74de3d6b4c4d1fc73173048bb9984 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 5 Aug 2026 01:03:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20show=5Fhtml=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=A0=8F=E6=94=B9=E4=B8=BA=20=E2=8B=AF=20?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/src/app/bootstrap.ts | 394 +++++++++++++++--- .../styles/components/chat/_chat-area.scss | 180 +++++++- 2 files changed, 501 insertions(+), 73 deletions(-) diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts index 9e228999..a37eeed3 100644 --- a/static/src/app/bootstrap.ts +++ b/static/src/app/bootstrap.ts @@ -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 = + '
'; + +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 = - ''; - 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 = + ''; + 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() { diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index af6b66dd..f3fa58c9 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -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;