feat(chat): 文件卡片新增 HTML 全屏预览

- 抽取 showHtmlSandbox/showHtmlFullscreen 共享模块(CSP/守卫/srcdoc/overlay)
- ShowFileCard 对 .html/.htm 增加预览按钮,复用卡片全屏 overlay
- 全屏 payload 支持 title/notice,检测到外部资源引用时顶栏提示
This commit is contained in:
JOJO 2026-08-05 02:54:58 +08:00
parent ea232d6ac6
commit d2e4422c80
5 changed files with 513 additions and 342 deletions

View File

@ -3,6 +3,12 @@ import katex from 'katex';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { createApp } from 'vue'; import { createApp } from 'vue';
import ShowFileCard from '../components/chat/ShowFileCard.vue'; import ShowFileCard from '../components/chat/ShowFileCard.vue';
import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox';
import {
openShowHtmlFullscreen,
closeShowHtmlFullscreen,
type ShowHtmlFullscreenPayload
} from '../utils/showHtmlFullscreen';
let showImageRenderSeq = 0; let showImageRenderSeq = 0;
let showImageDebugLogCount = 0; let showImageDebugLogCount = 0;
@ -293,209 +299,6 @@ 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' Workerobject/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) {
const content = (rawHtml || '').trim();
if (!content) {
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
}
// 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。
if (/<html[\s>]/i.test(content)) {
return injectShowHtmlSandboxGuards(content);
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${buildShowHtmlSandboxInjection()}
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100%;
background: transparent;
scrollbar-width: thin;
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
}
html::-webkit-scrollbar, body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
html::-webkit-scrollbar-track, body::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb {
background: rgba(121, 109, 94, 0.48);
border-radius: 8px;
}
html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover {
background: rgba(121, 109, 94, 0.62);
}
</style>
</head>
<body>${content}</body>
</html>`;
}
function escapeHtml(input: string) { function escapeHtml(input: string) {
return input return input
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@ -505,16 +308,6 @@ function escapeHtml(input: string) {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
/**
* show_html
* - js="on" CSP/ srcdoc
* - js="off" sanitize HTML srcdoc
*/
interface ShowHtmlFullscreenPayload {
srcdoc: string;
allowScripts: boolean;
}
interface ShowHtmlCardControls { interface ShowHtmlCardControls {
onRefresh: () => void; onRefresh: () => void;
getFullscreenPayload: () => ShowHtmlFullscreenPayload | null; getFullscreenPayload: () => ShowHtmlFullscreenPayload | null;
@ -650,135 +443,6 @@ function openShowHtmlCardMenu(anchor: HTMLElement, wrapper: HTMLElement) {
showHtmlCardMenuAnchor = anchor; 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 / * show_html /
* wrapper * wrapper

View File

@ -4,6 +4,15 @@
<span class="sfc-file-icon" aria-hidden="true"></span> <span class="sfc-file-icon" aria-hidden="true"></span>
<span class="sfc-name" :title="displayName">{{ displayName }}</span> <span class="sfc-name" :title="displayName">{{ displayName }}</span>
<div class="sfc-actions"> <div class="sfc-actions">
<button
v-if="isHtmlFile"
type="button"
class="sfc-btn"
:disabled="previewLoading"
@click="openHtmlPreview"
>
{{ previewLoading ? '加载中…' : '预览' }}
</button>
<button v-if="canCopy" type="button" class="sfc-btn" @click="copyContent"> <button v-if="canCopy" type="button" class="sfc-btn" @click="copyContent">
{{ copied ? '已复制' : '复制' }} {{ copied ? '已复制' : '复制' }}
</button> </button>
@ -69,6 +78,8 @@
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight'; import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
import { renderMarkdown } from '../../composables/useMarkdownRenderer'; import { renderMarkdown } from '../../composables/useMarkdownRenderer';
import { buildShowHtmlIframeSrcdoc } from '@/utils/showHtmlSandbox';
import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen';
import PdfPreview from './PdfPreview.vue'; import PdfPreview from './PdfPreview.vue';
// fetch // fetch
@ -105,6 +116,7 @@ const error = ref('');
const rawContent = ref(''); const rawContent = ref('');
const copied = ref(false); const copied = ref(false);
const contentUrl = ref(''); const contentUrl = ref('');
const previewLoading = ref(false);
// ---- ---- // ---- ----
const fileType = computed(() => props.type || inferShowFileType(props.path)); const fileType = computed(() => props.type || inferShowFileType(props.path));
@ -119,6 +131,9 @@ const canCopy = computed(() =>
['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value) ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value)
); );
// HTML
const isHtmlFile = computed(() => /\.html?$/i.test(props.path));
const isAndroidApp = computed(() => { const isAndroidApp = computed(() => {
return ( return (
typeof (window as any).AndroidDownloadBridge !== 'undefined' || typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
@ -251,6 +266,72 @@ function buildContentUrl() {
return `/api/file/content?path=${encodeURIComponent(props.path)}`; return `/api/file/content?path=${encodeURIComponent(props.path)}`;
} }
/**
* 检测 HTML 中的外部资源引用src/href
* srcdoc iframe 无基础 URL相对路径与根路径引用都会解析失败
* 绝对 URL含协议相对 data/blob/锚点不受影响
*/
function detectExternalResourceRefs(html: string): boolean {
const attrPattern = /\b(?:src|href)\s*=\s*["']([^"']*)["']/gi;
let match: RegExpExecArray | null;
while ((match = attrPattern.exec(html))) {
const url = (match[1] || '').trim();
if (!url || url.startsWith('#')) continue;
if (/^(https?:)?\/\//i.test(url)) continue;
if (/^(data|blob|mailto|tel|javascript):/i.test(url)) continue;
return true;
}
return false;
}
/**
* 全屏预览 HTML 文件V1面向自包含单文件
* show_html js=on 卡片同策略注入 CSP/守卫脚本沙箱 allow-scripts
* 检测到外部资源引用时在顶栏提示可能显示不完整
*/
async function openHtmlPreview() {
if (previewLoading.value) return;
previewLoading.value = true;
error.value = '';
try {
let content = rawContent.value || getCachedShowFileContent(props.path) || '';
if (!content) {
const resp = await fetch(buildContentUrl());
if (!resp.ok) {
let msg = resp.statusText;
try {
const j = await resp.json();
msg = j.error || msg;
} catch {
// JSON 沿 statusText
}
error.value = msg || '加载失败';
return;
}
content = await resp.text();
setCachedShowFileContent(props.path, content);
//
rawContent.value = content;
}
if (!content.trim()) {
error.value = '文件内容为空';
return;
}
openShowHtmlFullscreen({
srcdoc: buildShowHtmlIframeSrcdoc(content),
allowScripts: true,
title: displayName.value,
notice: detectExternalResourceRefs(content)
? '含外部资源引用,预览可能不完整'
: undefined
});
} catch (e) {
error.value = (e as Error).message || '网络错误';
} finally {
previewLoading.value = false;
}
}
async function loadContent() { async function loadContent() {
if (!canPreview.value) return; if (!canPreview.value) return;
error.value = ''; error.value = '';
@ -428,6 +509,16 @@ onMounted(() => {
background: var(--surface-muted); background: var(--surface-muted);
border-color: var(--border-strong); border-color: var(--border-strong);
} }
&:disabled {
opacity: 0.6;
cursor: default;
&:hover {
background: var(--surface-base);
border-color: var(--border-default);
}
}
} }
.sfc-btn-download { .sfc-btn-download {

View File

@ -1901,6 +1901,15 @@ html.show-html-fullscreen-open body {
border-bottom: 1px solid var(--border-default); border-bottom: 1px solid var(--border-default);
} }
/* 标题 + 提示小字容器:标题优先收缩省略,提示文字更弱一级 */
.show-html-fullscreen__heading {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.show-html-fullscreen__title { .show-html-fullscreen__title {
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
@ -1908,6 +1917,17 @@ html.show-html-fullscreen-open body {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
min-width: 0;
}
.show-html-fullscreen__notice {
flex: 0 1 auto;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.show-html-fullscreen__actions { .show-html-fullscreen__actions {

View File

@ -0,0 +1,185 @@
/**
* show_html overlay fixed Fullscreen API
* app/bootstrap.ts HTML ShowFileCard
* - js="on" CSP/ srcdoc
* - js="off" sanitize HTML srcdoc
* - HTML js="on" CSP/allow-scripts
*/
import { EMPTY_SHOW_HTML_SRCDOC } from './showHtmlSandbox';
export interface ShowHtmlFullscreenPayload {
srcdoc: string;
allowScripts: boolean;
/** 顶栏标题(如文件名),缺省显示「全屏预览」 */
title?: string;
/** 顶栏标题旁的提示小字(如「含外部资源引用,预览可能不完整」),缺省隐藏 */
notice?: string;
}
interface ShowHtmlFullscreenState {
root: HTMLElement;
iframe: HTMLIFrameElement;
titleEl: HTMLElement;
noticeEl: HTMLElement;
payload: ShowHtmlFullscreenPayload | null;
}
// 与 bootstrap.ts 的 showHtml 调试通道一致window.__SHOW_HTML_DEBUG__ 或
// localStorage.showHtmlDebug = '1' 开启,统一 [SHOW_HTML_DEBUG] 前缀便于一次性筛选
const SHOW_HTML_DEBUG_MAX_LOGS = 500;
let debugLogCount = 0;
function isDebugEnabled(): boolean {
if (typeof window === 'undefined') return false;
try {
const explicitFlag = (window as any).__SHOW_HTML_DEBUG__;
if (explicitFlag === true || explicitFlag === '1') return true;
if (explicitFlag === false || explicitFlag === '0') return false;
const localFlag = window.localStorage?.getItem('showHtmlDebug');
return localFlag === '1' || localFlag === 'true';
} catch {
return false;
}
}
function debugLog(event: string, payload: Record<string, unknown> = {}) {
if (!isDebugEnabled()) return;
if (debugLogCount >= SHOW_HTML_DEBUG_MAX_LOGS) return;
debugLogCount += 1;
if (debugLogCount === SHOW_HTML_DEBUG_MAX_LOGS) {
console.warn('[SHOW_HTML_DEBUG]', 'log-limit-reached', { max: SHOW_HTML_DEBUG_MAX_LOGS });
return;
}
console.log('[SHOW_HTML_DEBUG]', event, payload);
}
let showHtmlFullscreenState: ShowHtmlFullscreenState | null = null;
let showHtmlFullscreenEventsBound = false;
export 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 heading = document.createElement('div');
heading.className = 'show-html-fullscreen__heading';
const title = document.createElement('span');
title.className = 'show-html-fullscreen__title';
title.textContent = '全屏预览';
const notice = document.createElement('span');
notice.className = 'show-html-fullscreen__notice';
notice.style.display = 'none';
heading.appendChild(title);
heading.appendChild(notice);
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(heading);
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 = EMPTY_SHOW_HTML_SRCDOC;
requestAnimationFrame(() => {
if (showHtmlFullscreenState?.payload) {
showHtmlFullscreenState.iframe.srcdoc = keep;
}
});
debugLog('fullscreen:refresh', { srcdocLength: keep.length });
};
closeBtn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
closeShowHtmlFullscreen();
};
document.body.appendChild(root);
showHtmlFullscreenState = { root, iframe, titleEl: title, noticeEl: notice, payload: null };
return showHtmlFullscreenState;
}
export function openShowHtmlFullscreen(payload: ShowHtmlFullscreenPayload) {
if (typeof document === 'undefined') return;
const state = ensureShowHtmlFullscreenDom();
state.payload = payload;
state.titleEl.textContent = payload.title || '全屏预览';
const noticeText = (payload.notice || '').trim();
state.noticeEl.textContent = noticeText;
state.noticeEl.style.display = noticeText ? '' : 'none';
// 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');
debugLog('fullscreen:open', {
allowScripts: payload.allowScripts,
srcdocLength: payload.srcdoc.length,
hasNotice: !!noticeText
});
}
export 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 = EMPTY_SHOW_HTML_SRCDOC;
debugLog('fullscreen:close', {});
}

View File

@ -0,0 +1,211 @@
/**
* show_html srcdoc DOM
* app/bootstrap.ts HTML 线ShowFileCard
*/
/**
* 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' Workerobject/form/base
* iframe csp srcdoc w3c/webappsec-csp#492 meta
*/
export 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
*
*/
export 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 必须早于任何资源引用,守卫脚本必须早于任何卡片脚本 */
export function buildShowHtmlSandboxInjection(): string {
return `<meta http-equiv="Content-Security-Policy" content="${SHOW_HTML_IFRAME_CSP}" /><script>${SHOW_HTML_IFRAME_GUARD_SCRIPT}</script>`;
}
/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 <head> 最前(无 head 则补一个) */
export function injectShowHtmlSandboxGuards(doc: string): 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;
}
/** 空 srcdoc刷新/关闭全屏时挂载,强制 iframe 内页面重载并释放脚本与内存 */
export const EMPTY_SHOW_HTML_SRCDOC =
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
export function buildShowHtmlIframeSrcdoc(rawHtml: string): string {
const content = (rawHtml || '').trim();
if (!content) {
return EMPTY_SHOW_HTML_SRCDOC;
}
// 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。
if (/<html[\s>]/i.test(content)) {
return injectShowHtmlSandboxGuards(content);
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${buildShowHtmlSandboxInjection()}
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100%;
background: transparent;
scrollbar-width: thin;
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
}
html::-webkit-scrollbar, body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
html::-webkit-scrollbar-track, body::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb {
background: rgba(121, 109, 94, 0.48);
border-radius: 8px;
}
html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover {
background: rgba(121, 109, 94, 0.62);
}
</style>
</head>
<body>${content}</body>
</html>`;
}