Compare commits
2 Commits
677604b538
...
2db77fe663
| Author | SHA1 | Date | |
|---|---|---|---|
| 2db77fe663 | |||
| 79b916f44c |
@ -168,14 +168,8 @@ const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
|
|||||||
'4:3': 4 / 3,
|
'4:3': 4 / 3,
|
||||||
'3:4': 3 / 4
|
'3:4': 3 / 4
|
||||||
};
|
};
|
||||||
// show_html 统一按比例对应预设基准像素渲染(前端仍会按可用空间自适应)
|
// 卡片尺寸的基准像素与上限约束由 CSS 表达(见 _chat-area.scss 的 show_html[data-rendered] 规则),
|
||||||
const SHOW_HTML_RATIO_BASE_SIZE: Record<string, { width: number; height: number }> = {
|
// 此处仅保留比例数值用于写入 --show-html-ar CSS 变量。
|
||||||
'1:1': { width: 620, height: 620 },
|
|
||||||
'16:9': { width: 620, height: 349 },
|
|
||||||
'9:16': { width: 620, height: 1102 },
|
|
||||||
'4:3': { width: 620, height: 465 },
|
|
||||||
'3:4': { width: 620, height: 827 }
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeShowHtmlRatio(raw: string | null) {
|
function normalizeShowHtmlRatio(raw: string | null) {
|
||||||
if (!raw) return '1:1';
|
if (!raw) return '1:1';
|
||||||
@ -224,34 +218,7 @@ function readShowHtmlJsMode(node: Element) {
|
|||||||
return normalizeShowHtmlJsMode(node.getAttribute('js'));
|
return normalizeShowHtmlJsMode(node.getAttribute('js'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeAdaptiveShowHtmlSize(node: Element, ratioKey: string) {
|
|
||||||
const ratio = SHOW_HTML_RATIO_VALUES[ratioKey] || 1;
|
|
||||||
const base = SHOW_HTML_RATIO_BASE_SIZE[ratioKey] || SHOW_HTML_RATIO_BASE_SIZE['1:1'];
|
|
||||||
const windowW = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 320);
|
|
||||||
const windowH = Math.max(360, window.innerHeight || document.documentElement.clientHeight || 360);
|
|
||||||
const parentRect = node.parentElement?.getBoundingClientRect();
|
|
||||||
const flowRect =
|
|
||||||
node.closest('.messages-flow')?.getBoundingClientRect() ||
|
|
||||||
node.closest('.message-text')?.getBoundingClientRect() ||
|
|
||||||
node.closest('.text-content')?.getBoundingClientRect() ||
|
|
||||||
node.closest('.messages-area')?.getBoundingClientRect() ||
|
|
||||||
null;
|
|
||||||
const availableWidth = Math.max(
|
|
||||||
220,
|
|
||||||
Math.floor(Math.min(flowRect?.width || windowW, parentRect?.width || windowW) - 8)
|
|
||||||
);
|
|
||||||
// 移除“二次约束”后:窗口/容器足够大时,严格使用 prompt 约定的基准像素;
|
|
||||||
// 仅在物理空间不足时按比例等比缩小(不再做额外的最小/最大高度钳制)。
|
|
||||||
// 最大卡片约束:上限按 1200x900(窗口不足时仍按可用空间缩放)
|
|
||||||
const limitWidth = Math.max(220, Math.min(availableWidth, Math.floor(windowW * 0.96), 1200));
|
|
||||||
const limitHeight = Math.max(180, Math.min(Math.floor(windowH * 0.9), 900));
|
|
||||||
const widthScale = limitWidth / base.width;
|
|
||||||
const heightScale = limitHeight / base.height;
|
|
||||||
const scale = Math.min(1, widthScale, heightScale);
|
|
||||||
const widthPx = Math.max(220, Math.round(base.width * scale));
|
|
||||||
const heightPx = Math.round(widthPx / ratio);
|
|
||||||
return { widthPx, heightPx, ratio };
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeBase64Utf8(input: string) {
|
function decodeBase64Utf8(input: string) {
|
||||||
if (!input) return '';
|
if (!input) return '';
|
||||||
@ -326,15 +293,171 @@ function sanitizeShowHtmlContent(rawHtml: string) {
|
|||||||
return doc.body.innerHTML;
|
return doc.body.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* show_html js=on 沙箱 CSP(通过 srcdoc 内 <meta> 交付,必须位于任何资源引用之前)。
|
||||||
|
* - connect-src 'none':禁 fetch/XHR/WebSocket/EventSource,杜绝数据外发与内网探测
|
||||||
|
* - script/style/img/font/media 允许 https::保留卡片引用 CDN 资源与外链图的能力
|
||||||
|
* (残余风险:GET 类资源加载可作为信标外发少量数据,已与需求方确认接受)
|
||||||
|
* - frame/child/worker-src 'none':禁嵌套远程框架与 Worker;object/form/base 收紧
|
||||||
|
* 注:iframe 的 csp 属性对 srcdoc 的支持仍是未决规范(w3c/webappsec-csp#492),故走 meta 交付。
|
||||||
|
*/
|
||||||
|
const SHOW_HTML_IFRAME_CSP = [
|
||||||
|
"default-src 'none'",
|
||||||
|
"script-src 'unsafe-inline' https:",
|
||||||
|
"style-src 'unsafe-inline' https:",
|
||||||
|
"img-src data: blob: https:",
|
||||||
|
"font-src data: https:",
|
||||||
|
"media-src data: blob: https:",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"child-src 'none'",
|
||||||
|
"worker-src 'none'",
|
||||||
|
"object-src 'none'"
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* show_html js=on 沙箱守卫脚本(注入 srcdoc 最前,先于任何卡片脚本执行)。
|
||||||
|
* 解决问题:iframe 内 scrollIntoView()/focus() 的滚动副作用会穿透 iframe 边界滚动宿主页面
|
||||||
|
* (CSSOM View 规范行为,sandbox 属性与 CSS 均无法声明式禁止,见 w3c/csswg-drafts#7134)。
|
||||||
|
* 策略:重写相关 API,使滚动只发生在本 iframe 文档内部;拦截同文档锚点导航的默认滚动;
|
||||||
|
* 对同源子 frame 递归修补以收窄“干净原型”绕过窗口。
|
||||||
|
* 已知边界:不防“针对本守卫的确定性绕过”(威胁模型为提示注入生成的一般恶意内容)。
|
||||||
|
*/
|
||||||
|
const SHOW_HTML_IFRAME_GUARD_SCRIPT = `(function () {
|
||||||
|
'use strict';
|
||||||
|
if (window.__astrionSandboxGuard) return;
|
||||||
|
window.__astrionSandboxGuard = true;
|
||||||
|
|
||||||
|
function scrollingContainerList(el) {
|
||||||
|
var list = [];
|
||||||
|
var node = el.parentElement;
|
||||||
|
while (node && node !== document.body && node !== document.documentElement) {
|
||||||
|
var s = window.getComputedStyle(node);
|
||||||
|
if (/(auto|scroll|overlay)/.test(String(s.overflow) + String(s.overflowY) + String(s.overflowX))) {
|
||||||
|
list.push(node);
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
list.push(document.scrollingElement || document.documentElement);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollOneContainer(container, el, block) {
|
||||||
|
var isRoot = container === (document.scrollingElement || document.documentElement);
|
||||||
|
var eRect = el.getBoundingClientRect();
|
||||||
|
var viewH = isRoot ? window.innerHeight : container.clientHeight;
|
||||||
|
var top = isRoot ? eRect.top : eRect.top - container.getBoundingClientRect().top;
|
||||||
|
var bottom = top + eRect.height;
|
||||||
|
var delta = 0;
|
||||||
|
if (block === 'start') delta = top;
|
||||||
|
else if (block === 'end') delta = bottom - viewH;
|
||||||
|
else if (block === 'center') delta = top - (viewH - eRect.height) / 2;
|
||||||
|
else if (top < 0) delta = top;
|
||||||
|
else if (bottom > viewH) delta = Math.min(bottom - viewH, top);
|
||||||
|
if (delta) container.scrollTop += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localScrollIntoView(el, arg) {
|
||||||
|
var block = 'start';
|
||||||
|
if (arg === false) block = 'end';
|
||||||
|
else if (arg && typeof arg === 'object' && typeof arg.block === 'string') block = arg.block;
|
||||||
|
var containers = scrollingContainerList(el);
|
||||||
|
for (var i = 0; i < containers.length; i++) {
|
||||||
|
scrollOneContainer(containers[i], el, block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Element.prototype.scrollIntoView = function (arg) { localScrollIntoView(this, arg); };
|
||||||
|
if ('scrollIntoViewIfNeeded' in Element.prototype) {
|
||||||
|
Element.prototype.scrollIntoViewIfNeeded = function () { localScrollIntoView(this, { block: 'nearest' }); };
|
||||||
|
}
|
||||||
|
|
||||||
|
var origFocus = HTMLElement.prototype.focus;
|
||||||
|
HTMLElement.prototype.focus = function (options) {
|
||||||
|
var opts = options && typeof options === 'object'
|
||||||
|
? Object.assign({}, options, { preventScroll: true })
|
||||||
|
: { preventScroll: true };
|
||||||
|
origFocus.call(this, opts);
|
||||||
|
localScrollIntoView(this, { block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var t = ev.target;
|
||||||
|
var anchor = t && t.closest ? t.closest('a[href^="#"]') : null;
|
||||||
|
if (!anchor) return;
|
||||||
|
var id = (anchor.getAttribute('href') || '').slice(1);
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!id) return;
|
||||||
|
var target = document.getElementById(id);
|
||||||
|
if (target) {
|
||||||
|
// 先摘掉 id 再更新 hash,避免浏览器默认锚点滚动穿透到宿主页面
|
||||||
|
target.removeAttribute('id');
|
||||||
|
try { window.location.hash = id; } catch (err) { /* opaque origin 下忽略 */ }
|
||||||
|
target.setAttribute('id', id);
|
||||||
|
localScrollIntoView(target, { block: 'start' });
|
||||||
|
} else {
|
||||||
|
try { window.location.hash = id; } catch (err) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
function patchFrame(win) {
|
||||||
|
try {
|
||||||
|
win.Element.prototype.scrollIntoView = Element.prototype.scrollIntoView;
|
||||||
|
win.HTMLElement.prototype.focus = HTMLElement.prototype.focus;
|
||||||
|
} catch (err) { /* 跨源子 frame 跳过 */ }
|
||||||
|
}
|
||||||
|
new MutationObserver(function (mutations) {
|
||||||
|
for (var i = 0; i < mutations.length; i++) {
|
||||||
|
var added = mutations[i].addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) {
|
||||||
|
var n = added[j];
|
||||||
|
if (!n || n.nodeType !== 1) continue;
|
||||||
|
var frames = [];
|
||||||
|
if (n.tagName === 'IFRAME' || n.tagName === 'FRAME') frames.push(n);
|
||||||
|
if (n.querySelectorAll) {
|
||||||
|
var found = n.querySelectorAll('iframe,frame');
|
||||||
|
for (var k = 0; k < found.length; k++) frames.push(found[k]);
|
||||||
|
}
|
||||||
|
for (var m = 0; m < frames.length; m++) {
|
||||||
|
if (frames[m].contentWindow) patchFrame(frames[m].contentWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
/** 生成注入内容:CSP meta 必须早于任何资源引用,守卫脚本必须早于任何卡片脚本 */
|
||||||
|
function buildShowHtmlSandboxInjection() {
|
||||||
|
return `<meta http-equiv="Content-Security-Policy" content="${SHOW_HTML_IFRAME_CSP}" /><script>${SHOW_HTML_IFRAME_GUARD_SCRIPT}</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 <head> 最前(无 head 则补一个) */
|
||||||
|
function injectShowHtmlSandboxGuards(doc: string) {
|
||||||
|
const injection = buildShowHtmlSandboxInjection();
|
||||||
|
const headMatch = /<head\b[^>]*>/i.exec(doc);
|
||||||
|
if (headMatch) {
|
||||||
|
const idx = headMatch.index + headMatch[0].length;
|
||||||
|
return doc.slice(0, idx) + injection + doc.slice(idx);
|
||||||
|
}
|
||||||
|
const htmlMatch = /<html\b[^>]*>/i.exec(doc);
|
||||||
|
if (htmlMatch) {
|
||||||
|
const idx = htmlMatch.index + htmlMatch[0].length;
|
||||||
|
return doc.slice(0, idx) + `<head>${injection}</head>` + doc.slice(idx);
|
||||||
|
}
|
||||||
|
return injection + doc;
|
||||||
|
}
|
||||||
|
|
||||||
function buildShowHtmlIframeSrcdoc(rawHtml: string) {
|
function buildShowHtmlIframeSrcdoc(rawHtml: string) {
|
||||||
const content = (rawHtml || '').trim();
|
const content = (rawHtml || '').trim();
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。
|
// 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。
|
||||||
if (/<html[\s>]/i.test(content)) {
|
if (/<html[\s>]/i.test(content)) {
|
||||||
return content;
|
return injectShowHtmlSandboxGuards(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@ -342,6 +465,7 @@ function buildShowHtmlIframeSrcdoc(rawHtml: string) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
${buildShowHtmlSandboxInjection()}
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -381,42 +505,309 @@ function escapeHtml(input: string) {
|
|||||||
.replace(/'/g, ''');
|
.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;
|
let toolbar = wrapper.querySelector(':scope > .chat-inline-card__toolbar') as HTMLElement | null;
|
||||||
if (!toolbar) {
|
if (!toolbar) {
|
||||||
toolbar = document.createElement('div');
|
toolbar = document.createElement('div');
|
||||||
toolbar.className = 'chat-inline-card__toolbar';
|
toolbar.className = 'chat-inline-card__toolbar';
|
||||||
wrapper.appendChild(toolbar);
|
wrapper.appendChild(toolbar);
|
||||||
}
|
}
|
||||||
let refreshBtn = toolbar.querySelector(
|
let menuBtn = toolbar.querySelector(
|
||||||
':scope > .chat-inline-card__refresh-btn'
|
':scope > .chat-inline-card__menu-btn'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
if (!refreshBtn) {
|
if (!menuBtn) {
|
||||||
refreshBtn = document.createElement('button');
|
menuBtn = document.createElement('button');
|
||||||
refreshBtn.type = 'button';
|
menuBtn.type = 'button';
|
||||||
refreshBtn.className = 'chat-inline-card__refresh-btn';
|
menuBtn.className = 'chat-inline-card__menu-btn';
|
||||||
refreshBtn.title = '刷新卡片';
|
menuBtn.title = '卡片操作';
|
||||||
refreshBtn.setAttribute('aria-label', '刷新卡片');
|
menuBtn.setAttribute('aria-label', '卡片操作');
|
||||||
refreshBtn.textContent = '↻';
|
menuBtn.setAttribute('aria-haspopup', 'menu');
|
||||||
toolbar.appendChild(refreshBtn);
|
menuBtn.textContent = '⋯';
|
||||||
|
toolbar.appendChild(menuBtn);
|
||||||
}
|
}
|
||||||
refreshBtn.onclick = (event) => {
|
menuBtn.onclick = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
markShowTagDrawingActive(1200);
|
openShowHtmlCardMenu(menuBtn!, wrapper);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -552,7 +943,6 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
const jsMode = readShowHtmlJsMode(node);
|
const jsMode = readShowHtmlJsMode(node);
|
||||||
const computedRatioKey = readShowHtmlRatio(node);
|
const computedRatioKey = readShowHtmlRatio(node);
|
||||||
let ratioKey = computedRatioKey;
|
let ratioKey = computedRatioKey;
|
||||||
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
|
|
||||||
|
|
||||||
// 流式阶段:一旦 show_html 已经出现完整闭合(非 partial),冻结该 path 的已完成快照;
|
// 流式阶段:一旦 show_html 已经出现完整闭合(非 partial),冻结该 path 的已完成快照;
|
||||||
// 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。
|
// 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。
|
||||||
@ -571,47 +961,31 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
const effectiveEncoded = frozenSnapshot?.encoded || encoded;
|
const effectiveEncoded = frozenSnapshot?.encoded || encoded;
|
||||||
if (frozenSnapshot?.ratioKey) {
|
if (frozenSnapshot?.ratioKey) {
|
||||||
ratioKey = frozenSnapshot.ratioKey;
|
ratioKey = frozenSnapshot.ratioKey;
|
||||||
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流式期间锁定卡片比例(尺寸由 CSS 自适应,无需锁像素):
|
||||||
|
// 流式初期 ratio 属性可能未输出完整而先落到 1:1,后续允许升级到真实比例
|
||||||
if (inStreaming) {
|
if (inStreaming) {
|
||||||
const locked = showHtmlStreamingSizeLockByPath.get(pathKey);
|
const locked = showHtmlStreamingRatioLockByPath.get(pathKey);
|
||||||
if (locked) {
|
if (locked) {
|
||||||
// 修复:流式初期若 ratio 解析不完整会先落到 1:1,后续必须允许升级到真实比例
|
|
||||||
const shouldUpgradeRatio =
|
const shouldUpgradeRatio =
|
||||||
ratioKey !== locked.ratioKey && (locked.ratioKey === '1:1' || ratioKey !== '1:1');
|
ratioKey !== locked && (locked === '1:1' || ratioKey !== '1:1');
|
||||||
if (shouldUpgradeRatio) {
|
if (shouldUpgradeRatio) {
|
||||||
ratioKey = computedRatioKey;
|
ratioKey = computedRatioKey;
|
||||||
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
|
showHtmlStreamingRatioLockByPath.set(pathKey, ratioKey);
|
||||||
showHtmlStreamingSizeLockByPath.set(pathKey, {
|
|
||||||
ratioKey,
|
|
||||||
widthPx,
|
|
||||||
heightPx
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
ratioKey = locked.ratioKey;
|
ratioKey = locked;
|
||||||
widthPx = locked.widthPx;
|
|
||||||
heightPx = locked.heightPx;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showHtmlStreamingSizeLockByPath.set(pathKey, {
|
showHtmlStreamingRatioLockByPath.set(pathKey, ratioKey);
|
||||||
ratioKey,
|
|
||||||
widthPx,
|
|
||||||
heightPx
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showHtmlStreamingSizeLockByPath.delete(pathKey);
|
showHtmlStreamingRatioLockByPath.delete(pathKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// streaming 阶段 v-html 会反复重建 show_html 节点,这里先锁定占位尺寸,避免高度掉 0
|
// 卡片尺寸由 CSS 接管(width: min(...) + aspect-ratio,见 _chat-area.scss),
|
||||||
node.style.display = 'block';
|
// 这里只写入比例变量;窗口/容器尺寸变化时浏览器自动重算,无需 JS 监听 resize
|
||||||
node.style.width = `${widthPx}px`;
|
node.style.setProperty('--show-html-ar', String(SHOW_HTML_RATIO_VALUES[ratioKey] || 1));
|
||||||
node.style.maxWidth = '100%';
|
|
||||||
node.style.minHeight = `${heightPx}px`;
|
|
||||||
node.style.margin = '14px auto';
|
|
||||||
node.style.boxSizing = 'border-box';
|
|
||||||
node.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
debugShowHtmlLog('render:node-seen', {
|
debugShowHtmlLog('render:node-seen', {
|
||||||
renderId,
|
renderId,
|
||||||
@ -624,9 +998,7 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
partial: node.getAttribute('data-partial') || '',
|
partial: node.getAttribute('data-partial') || '',
|
||||||
encodedLength: encoded.length,
|
encodedLength: encoded.length,
|
||||||
effectiveEncodedLength: effectiveEncoded.length,
|
effectiveEncodedLength: effectiveEncoded.length,
|
||||||
innerLength: (node.innerHTML || '').length,
|
innerLength: (node.innerHTML || '').length
|
||||||
reservedWidth: `${widthPx}px`,
|
|
||||||
reservedMinHeight: `${heightPx}px`
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
|
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
|
||||||
@ -643,29 +1015,15 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
tip.textContent = '正在渲染内容...';
|
tip.textContent = '正在渲染内容...';
|
||||||
host.appendChild(tip);
|
host.appendChild(tip);
|
||||||
wrapper.appendChild(host);
|
wrapper.appendChild(host);
|
||||||
pending = {
|
pending = { wrapper, host, tip };
|
||||||
wrapper,
|
|
||||||
host,
|
|
||||||
tip,
|
|
||||||
widthPx: 0,
|
|
||||||
heightPx: 0,
|
|
||||||
ratioKey: '1:1'
|
|
||||||
};
|
|
||||||
showHtmlJsPendingRenderByPath.set(pathKey, pending);
|
showHtmlJsPendingRenderByPath.set(pathKey, pending);
|
||||||
}
|
}
|
||||||
pending.wrapper.style.width = `${widthPx}px`;
|
|
||||||
pending.host.style.height = `${heightPx}px`;
|
|
||||||
pending.widthPx = widthPx;
|
|
||||||
pending.heightPx = heightPx;
|
|
||||||
pending.ratioKey = ratioKey;
|
|
||||||
node.replaceChildren(pending.wrapper);
|
node.replaceChildren(pending.wrapper);
|
||||||
node.setAttribute('data-rendered', '1');
|
node.setAttribute('data-rendered', '1');
|
||||||
debugShowHtmlLog('render:node-js-pending', {
|
debugShowHtmlLog('render:node-js-pending', {
|
||||||
renderId,
|
renderId,
|
||||||
pathKey,
|
pathKey,
|
||||||
ratioKey,
|
ratioKey
|
||||||
widthPx,
|
|
||||||
heightPx
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -684,49 +1042,49 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||||
iframe.setAttribute('referrerpolicy', 'no-referrer');
|
iframe.setAttribute('referrerpolicy', 'no-referrer');
|
||||||
iframe.setAttribute('loading', 'lazy');
|
iframe.setAttribute('loading', 'lazy');
|
||||||
|
// 空 allow 以外的特性显式拒绝:卡片是固定尺寸展示区,无全屏等合理需求
|
||||||
|
iframe.setAttribute('allow', "fullscreen 'none'");
|
||||||
wrapper.appendChild(iframe);
|
wrapper.appendChild(iframe);
|
||||||
persistent = {
|
persistent = {
|
||||||
encoded: '',
|
encoded: '',
|
||||||
ratioKey: '1:1',
|
ratioKey: '1:1',
|
||||||
widthPx: 0,
|
|
||||||
heightPx: 0,
|
|
||||||
srcdoc: '',
|
srcdoc: '',
|
||||||
wrapper,
|
wrapper,
|
||||||
iframe
|
iframe
|
||||||
};
|
};
|
||||||
showHtmlJsIframeRenderByPath.set(pathKey, persistent);
|
showHtmlJsIframeRenderByPath.set(pathKey, persistent);
|
||||||
}
|
}
|
||||||
bindShowHtmlRefreshControl(persistent.wrapper, () => {
|
bindShowHtmlCardControls(persistent.wrapper, {
|
||||||
// 强制重新挂载同一份 srcdoc,触发 iframe 内页面重载
|
onRefresh: () => {
|
||||||
const keep = persistent?.srcdoc || srcdoc;
|
// 强制重新挂载同一份 srcdoc,触发 iframe 内页面重载
|
||||||
persistent.iframe.srcdoc =
|
const keep = persistent?.srcdoc || srcdoc;
|
||||||
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
persistent.iframe.srcdoc =
|
||||||
requestAnimationFrame(() => {
|
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
||||||
if (persistent?.iframe) {
|
requestAnimationFrame(() => {
|
||||||
persistent.iframe.srcdoc = keep;
|
if (persistent?.iframe) {
|
||||||
}
|
persistent.iframe.srcdoc = keep;
|
||||||
});
|
}
|
||||||
debugShowHtmlLog('refresh:iframe', {
|
});
|
||||||
pathKey,
|
debugShowHtmlLog('refresh:iframe', {
|
||||||
ratioKey: persistent?.ratioKey || ratioKey,
|
pathKey,
|
||||||
widthPx: persistent?.widthPx || widthPx,
|
ratioKey: persistent?.ratioKey || ratioKey,
|
||||||
heightPx: persistent?.heightPx || heightPx,
|
srcdocLength: keep.length
|
||||||
srcdocLength: keep.length
|
});
|
||||||
});
|
},
|
||||||
|
// 全屏直接复用当前 srcdoc(已含 CSP meta 与守卫脚本),沙箱保持 allow-scripts
|
||||||
|
getFullscreenPayload: () => {
|
||||||
|
const current = persistent?.srcdoc || '';
|
||||||
|
if (!current) return null;
|
||||||
|
return { srcdoc: current, allowScripts: true };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
persistent.wrapper.style.width = `${widthPx}px`;
|
|
||||||
persistent.iframe.style.height = `${heightPx}px`;
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
persistent.encoded !== effectiveEncoded ||
|
persistent.encoded !== effectiveEncoded ||
|
||||||
persistent.widthPx !== widthPx ||
|
|
||||||
persistent.heightPx !== heightPx ||
|
|
||||||
persistent.ratioKey !== ratioKey ||
|
persistent.ratioKey !== ratioKey ||
|
||||||
persistent.srcdoc !== srcdoc;
|
persistent.srcdoc !== srcdoc;
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
persistent.iframe.srcdoc = srcdoc;
|
persistent.iframe.srcdoc = srcdoc;
|
||||||
persistent.encoded = effectiveEncoded;
|
persistent.encoded = effectiveEncoded;
|
||||||
persistent.widthPx = widthPx;
|
|
||||||
persistent.heightPx = heightPx;
|
|
||||||
persistent.ratioKey = ratioKey;
|
persistent.ratioKey = ratioKey;
|
||||||
persistent.srcdoc = srcdoc;
|
persistent.srcdoc = srcdoc;
|
||||||
}
|
}
|
||||||
@ -739,8 +1097,6 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
renderId,
|
renderId,
|
||||||
pathKey,
|
pathKey,
|
||||||
ratioKey,
|
ratioKey,
|
||||||
widthPx,
|
|
||||||
heightPx,
|
|
||||||
encodedLength: effectiveEncoded.length,
|
encodedLength: effectiveEncoded.length,
|
||||||
srcdocLength: srcdoc.length,
|
srcdocLength: srcdoc.length,
|
||||||
needsUpdate
|
needsUpdate
|
||||||
@ -754,16 +1110,12 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
if (now - lastTs < SHOW_HTML_STREAMING_RENDER_INTERVAL_MS) {
|
if (now - lastTs < SHOW_HTML_STREAMING_RENDER_INTERVAL_MS) {
|
||||||
const persistent = showHtmlPersistentRenderByPath.get(pathKey);
|
const persistent = showHtmlPersistentRenderByPath.get(pathKey);
|
||||||
if (persistent) {
|
if (persistent) {
|
||||||
persistent.wrapper.style.width = `${widthPx}px`;
|
|
||||||
persistent.host.style.height = `${heightPx}px`;
|
|
||||||
node.replaceChildren(persistent.wrapper);
|
node.replaceChildren(persistent.wrapper);
|
||||||
node.setAttribute('data-rendered', '1');
|
node.setAttribute('data-rendered', '1');
|
||||||
debugShowHtmlLog('render:node-throttled-rehydrate', {
|
debugShowHtmlLog('render:node-throttled-rehydrate', {
|
||||||
renderId,
|
renderId,
|
||||||
pathKey,
|
pathKey,
|
||||||
ratioKey,
|
ratioKey
|
||||||
widthPx,
|
|
||||||
heightPx
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
debugShowHtmlLog('render:node-throttled', {
|
debugShowHtmlLog('render:node-throttled', {
|
||||||
@ -790,9 +1142,7 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
effectiveEncodedLength: effectiveEncoded.length,
|
effectiveEncodedLength: effectiveEncoded.length,
|
||||||
rawLength: rawHtml.length,
|
rawLength: rawHtml.length,
|
||||||
safeLength: safeHtml.length,
|
safeLength: safeHtml.length,
|
||||||
ratioKey,
|
ratioKey
|
||||||
widthPx,
|
|
||||||
heightPx
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let persistent = showHtmlPersistentRenderByPath.get(pathKey);
|
let persistent = showHtmlPersistentRenderByPath.get(pathKey);
|
||||||
@ -852,52 +1202,48 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
encoded: '',
|
encoded: '',
|
||||||
safeHtml: '',
|
safeHtml: '',
|
||||||
ratioKey: '1:1',
|
ratioKey: '1:1',
|
||||||
widthPx: 0,
|
|
||||||
heightPx: 0,
|
|
||||||
wrapper,
|
wrapper,
|
||||||
host,
|
host,
|
||||||
root
|
root
|
||||||
};
|
};
|
||||||
showHtmlPersistentRenderByPath.set(pathKey, persistent);
|
showHtmlPersistentRenderByPath.set(pathKey, persistent);
|
||||||
}
|
}
|
||||||
bindShowHtmlRefreshControl(persistent.wrapper, () => {
|
bindShowHtmlCardControls(persistent.wrapper, {
|
||||||
const keep = persistent?.safeHtml || '';
|
onRefresh: () => {
|
||||||
persistent.root.innerHTML = '';
|
const keep = persistent?.safeHtml || '';
|
||||||
requestAnimationFrame(() => {
|
persistent.root.innerHTML = '';
|
||||||
if (persistent?.root) {
|
requestAnimationFrame(() => {
|
||||||
persistent.root.innerHTML = keep;
|
if (persistent?.root) {
|
||||||
}
|
persistent.root.innerHTML = keep;
|
||||||
});
|
}
|
||||||
debugShowHtmlLog('refresh:shadow-root', {
|
});
|
||||||
pathKey,
|
debugShowHtmlLog('refresh:shadow-root', {
|
||||||
ratioKey: persistent?.ratioKey || ratioKey,
|
pathKey,
|
||||||
widthPx: persistent?.widthPx || widthPx,
|
ratioKey: persistent?.ratioKey || ratioKey,
|
||||||
heightPx: persistent?.heightPx || heightPx,
|
safeLength: keep.length
|
||||||
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 };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
persistent.wrapper.style.width = `${widthPx}px`;
|
|
||||||
persistent.host.style.height = `${heightPx}px`;
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
persistent.encoded !== effectiveEncoded ||
|
persistent.encoded !== effectiveEncoded ||
|
||||||
persistent.widthPx !== widthPx ||
|
|
||||||
persistent.heightPx !== heightPx ||
|
|
||||||
persistent.ratioKey !== ratioKey;
|
persistent.ratioKey !== ratioKey;
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
persistent.root.innerHTML = safeHtml;
|
persistent.root.innerHTML = safeHtml;
|
||||||
persistent.encoded = effectiveEncoded;
|
persistent.encoded = effectiveEncoded;
|
||||||
persistent.safeHtml = safeHtml;
|
persistent.safeHtml = safeHtml;
|
||||||
persistent.widthPx = widthPx;
|
|
||||||
persistent.heightPx = heightPx;
|
|
||||||
persistent.ratioKey = ratioKey;
|
persistent.ratioKey = ratioKey;
|
||||||
debugShowHtmlLog('render:node-persistent-update', {
|
debugShowHtmlLog('render:node-persistent-update', {
|
||||||
renderId,
|
renderId,
|
||||||
pathKey,
|
pathKey,
|
||||||
encodedLength: effectiveEncoded.length,
|
encodedLength: effectiveEncoded.length,
|
||||||
ratioKey,
|
ratioKey
|
||||||
widthPx,
|
|
||||||
heightPx
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
debugShowHtmlLog('render:node-persistent-reuse', {
|
debugShowHtmlLog('render:node-persistent-reuse', {
|
||||||
@ -917,8 +1263,6 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
pathKey,
|
pathKey,
|
||||||
jsMode,
|
jsMode,
|
||||||
ratioKey,
|
ratioKey,
|
||||||
widthPx,
|
|
||||||
heightPx,
|
|
||||||
rect: {
|
rect: {
|
||||||
x: Math.round(rect.x),
|
x: Math.round(rect.x),
|
||||||
y: Math.round(rect.y),
|
y: Math.round(rect.y),
|
||||||
@ -933,8 +1277,6 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
debugShowImageLog('render:show-html-done', {
|
debugShowImageLog('render:show-html-done', {
|
||||||
renderId,
|
renderId,
|
||||||
ratioKey,
|
ratioKey,
|
||||||
widthPx,
|
|
||||||
heightPx,
|
|
||||||
contentLength: rawHtml.length
|
contentLength: rawHtml.length
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -970,22 +1312,14 @@ const showHtmlCompletedSnapshotByPath = new Map<
|
|||||||
ratioKey: string
|
ratioKey: string
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
const showHtmlStreamingSizeLockByPath = new Map<
|
// 流式期间只锁定比例;具体尺寸由 CSS min()+aspect-ratio 自适应计算
|
||||||
string,
|
const showHtmlStreamingRatioLockByPath = new Map<string, string>();
|
||||||
{
|
|
||||||
ratioKey: string,
|
|
||||||
widthPx: number,
|
|
||||||
heightPx: number
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
const showHtmlPersistentRenderByPath = new Map<
|
const showHtmlPersistentRenderByPath = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
encoded: string,
|
encoded: string,
|
||||||
safeHtml: string,
|
safeHtml: string,
|
||||||
ratioKey: string,
|
ratioKey: string,
|
||||||
widthPx: number,
|
|
||||||
heightPx: number,
|
|
||||||
wrapper: HTMLElement,
|
wrapper: HTMLElement,
|
||||||
host: HTMLElement,
|
host: HTMLElement,
|
||||||
root: HTMLElement
|
root: HTMLElement
|
||||||
@ -996,10 +1330,7 @@ const showHtmlJsPendingRenderByPath = new Map<
|
|||||||
{
|
{
|
||||||
wrapper: HTMLElement,
|
wrapper: HTMLElement,
|
||||||
host: HTMLElement,
|
host: HTMLElement,
|
||||||
tip: HTMLElement,
|
tip: HTMLElement
|
||||||
widthPx: number,
|
|
||||||
heightPx: number,
|
|
||||||
ratioKey: string
|
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
const showHtmlJsIframeRenderByPath = new Map<
|
const showHtmlJsIframeRenderByPath = new Map<
|
||||||
@ -1007,8 +1338,6 @@ const showHtmlJsIframeRenderByPath = new Map<
|
|||||||
{
|
{
|
||||||
encoded: string,
|
encoded: string,
|
||||||
ratioKey: string,
|
ratioKey: string,
|
||||||
widthPx: number,
|
|
||||||
heightPx: number,
|
|
||||||
srcdoc: string,
|
srcdoc: string,
|
||||||
wrapper: HTMLElement,
|
wrapper: HTMLElement,
|
||||||
iframe: HTMLIFrameElement
|
iframe: HTMLIFrameElement
|
||||||
@ -1318,11 +1647,14 @@ export function teardownShowImageObserver() {
|
|||||||
layoutDebugLastTsByKey.clear();
|
layoutDebugLastTsByKey.clear();
|
||||||
showHtmlStreamingRenderTsByPath.clear();
|
showHtmlStreamingRenderTsByPath.clear();
|
||||||
showHtmlCompletedSnapshotByPath.clear();
|
showHtmlCompletedSnapshotByPath.clear();
|
||||||
showHtmlStreamingSizeLockByPath.clear();
|
showHtmlStreamingRatioLockByPath.clear();
|
||||||
showHtmlPersistentRenderByPath.clear();
|
showHtmlPersistentRenderByPath.clear();
|
||||||
showHtmlJsPendingRenderByPath.clear();
|
showHtmlJsPendingRenderByPath.clear();
|
||||||
showHtmlJsIframeRenderByPath.clear();
|
showHtmlJsIframeRenderByPath.clear();
|
||||||
showTagObservedContainer = null;
|
showTagObservedContainer = null;
|
||||||
|
// 菜单/全屏层是全局单例,卡片 Map 清空后其引用的 wrapper 已卸载,必须一并关闭
|
||||||
|
closeShowHtmlCardMenu();
|
||||||
|
closeShowHtmlFullscreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateViewportHeightVar() {
|
function updateViewportHeightVar() {
|
||||||
|
|||||||
@ -38,7 +38,8 @@ const onMathRendered = inject<() => void>('mathRenderedCallback', () => {});
|
|||||||
const segments = computed(() => parseMarkdownSegments(props.content || '', props.isStreaming));
|
const segments = computed(() => parseMarkdownSegments(props.content || '', props.isStreaming));
|
||||||
|
|
||||||
function renderText(text: string) {
|
function renderText(text: string) {
|
||||||
return renderMarkdownText(text);
|
// 透传流式标志:show_html 卡片在流式期间需要 partial 渲染(实时渲染/渲染中占位)
|
||||||
|
return renderMarkdownText(text, props.isStreaming);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMath() {
|
function renderMath() {
|
||||||
|
|||||||
@ -715,12 +715,14 @@ export function parseMarkdownSegments(text: string, isStreaming = false): Markdo
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMarkdownText(text: string): string {
|
export function renderMarkdownText(text: string, isStreaming = false): string {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
|
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
|
||||||
|
// (js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
|
||||||
const safeText = transformMathBlocks(
|
const safeText = transformMathBlocks(
|
||||||
transformShowFileBlocks(
|
transformShowFileBlocks(
|
||||||
transformShowImageBlocks(transformShowHtmlBlocks(text, false))
|
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1736,6 +1736,15 @@ body[data-theme='dark'] .more-icon {
|
|||||||
.chat-inline-card--html {
|
.chat-inline-card--html {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/* 外层 show_html 元素已携带 margin 与 aspect-ratio 尺寸,wrapper 必须撑满父容器;
|
||||||
|
若叠加基类 margin 会超出父高度,被 overflow:hidden 裁掉底部边框与圆角 */
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-html__host {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-inline-card__toolbar {
|
.chat-inline-card__toolbar {
|
||||||
@ -1749,42 +1758,42 @@ body[data-theme='dark'] .more-icon {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-inline-card__refresh-btn {
|
/* ⋯ 菜单按钮:纯文字风格,无边框无背景,hover 仅变色;保留 26px 点击热区 */
|
||||||
|
.chat-inline-card__menu-btn {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 999px;
|
border: 0;
|
||||||
border: 1px solid var(--border-default);
|
background: transparent;
|
||||||
background: color-mix(in srgb, var(--surface-card) 78%, transparent);
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
/* ⋯ 字形视觉重心偏下,上抬补偵使其看起来居中 */
|
||||||
|
padding-bottom: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition: color 0.18s ease;
|
||||||
color 0.18s ease,
|
|
||||||
border-color 0.18s ease,
|
|
||||||
background 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-inline-card__refresh-btn:hover {
|
.chat-inline-card__menu-btn:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--highlight) 72%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-inline-card__refresh-btn.is-refreshing {
|
.chat-inline-card__menu-btn.is-refreshing {
|
||||||
animation: chat-inline-refresh-spin 0.6s linear 1;
|
animation: chat-inline-menu-pulse 0.6s ease 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes chat-inline-refresh-spin {
|
@keyframes chat-inline-menu-pulse {
|
||||||
from {
|
0% {
|
||||||
transform: rotate(0deg);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
40% {
|
||||||
transform: rotate(360deg);
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1809,6 +1818,148 @@ body[data-theme='dark'] .more-icon {
|
|||||||
background: transparent;
|
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 {
|
.chat-inline-image__error {
|
||||||
@extend .chat-inline-card__error;
|
@extend .chat-inline-card__error;
|
||||||
@ -1854,6 +2005,22 @@ show-html:not([data-rendered='1'])[ratio='3:4'] {
|
|||||||
aspect-ratio: 3 / 4;
|
aspect-ratio: 3 / 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 已渲染(enhance 后)的 show_html 卡片:尺寸完全由 CSS 计算,替代原 JS 内联像素方案。
|
||||||
|
宽度基准 620px,同时受容器宽度与高度上限(900px / 90dvh)按比例折算约束;
|
||||||
|
比例由 enhance 写入的 --show-html-ar 变量参与计算,窗口/容器变化时浏览器自动重算。 */
|
||||||
|
show_html[data-rendered='1'],
|
||||||
|
show-html[data-rendered='1'] {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 14px auto;
|
||||||
|
max-width: 100%;
|
||||||
|
--show-html-ar: 1;
|
||||||
|
width: min(620px, calc(100% - 8px), calc(min(90vh, 900px) * var(--show-html-ar)));
|
||||||
|
width: min(620px, calc(100% - 8px), calc(min(90dvh, 900px) * var(--show-html-ar)));
|
||||||
|
aspect-ratio: var(--show-html-ar);
|
||||||
|
}
|
||||||
|
|
||||||
.text-output .text-content {
|
.text-output .text-content {
|
||||||
padding: 0 var(--chat-content-x);
|
padding: 0 var(--chat-content-x);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user