feat(chat): 文件卡片新增 HTML 全屏预览
- 抽取 showHtmlSandbox/showHtmlFullscreen 共享模块(CSP/守卫/srcdoc/overlay) - ShowFileCard 对 .html/.htm 增加预览按钮,复用卡片全屏 overlay - 全屏 payload 支持 title/notice,检测到外部资源引用时顶栏提示
This commit is contained in:
parent
ea232d6ac6
commit
d2e4422c80
@ -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':禁嵌套远程框架与 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) {
|
|
||||||
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, '&')
|
.replace(/&/g, '&')
|
||||||
@ -505,16 +308,6 @@ function escapeHtml(input: string) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 上,按钮与菜单只创建一次。
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
185
static/src/utils/showHtmlFullscreen.ts
Normal file
185
static/src/utils/showHtmlFullscreen.ts
Normal 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', {});
|
||||||
|
}
|
||||||
211
static/src/utils/showHtmlSandbox.ts
Normal file
211
static/src/utils/showHtmlSandbox.ts
Normal 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':禁嵌套远程框架与 Worker;object/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>`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user