1292 lines
43 KiB
TypeScript
1292 lines
43 KiB
TypeScript
// @ts-nocheck
|
||
import katex from 'katex';
|
||
import DOMPurify from 'dompurify';
|
||
|
||
let showImageRenderSeq = 0;
|
||
let showImageDebugLogCount = 0;
|
||
const SHOW_IMAGE_DEBUG_VERSION = 'v2-lite-throttled';
|
||
const SHOW_IMAGE_DEBUG_MAX_LOGS = 200;
|
||
let showHtmlDebugLogCount = 0;
|
||
const SHOW_HTML_DEBUG_MAX_LOGS = 500;
|
||
|
||
function getShowImageDebugMode(): 'off' | 'lite' | 'verbose' {
|
||
if (typeof window === 'undefined') return 'off';
|
||
try {
|
||
const explicitFlag = (window as any).__SHOW_IMAGE_DEBUG__;
|
||
if (
|
||
explicitFlag === false ||
|
||
explicitFlag === '0' ||
|
||
explicitFlag === 'off' ||
|
||
explicitFlag === 'false'
|
||
) {
|
||
return 'off';
|
||
}
|
||
if (explicitFlag === 'verbose') return 'verbose';
|
||
if (explicitFlag === true || explicitFlag === '1' || explicitFlag === 'lite') return 'lite';
|
||
|
||
const localFlag = window.localStorage?.getItem('showImageDebug');
|
||
if (localFlag === '0' || localFlag === 'off' || localFlag === 'false' || localFlag === 'none') {
|
||
return 'off';
|
||
}
|
||
if (localFlag === 'verbose') return 'verbose';
|
||
if (localFlag === '1' || localFlag === 'true' || localFlag === 'lite') return 'lite';
|
||
return 'off';
|
||
} catch {
|
||
return 'off';
|
||
}
|
||
}
|
||
|
||
function debugShowImageLog(event: string, payload: Record<string, any> = {}) {
|
||
const mode = getShowImageDebugMode();
|
||
if (mode === 'off') return;
|
||
if (
|
||
mode === 'lite' &&
|
||
!['observer:setup', 'observer:show-image-mutation', 'render:start', 'render:end'].includes(
|
||
event
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
if (showImageDebugLogCount >= SHOW_IMAGE_DEBUG_MAX_LOGS) return;
|
||
showImageDebugLogCount += 1;
|
||
if (showImageDebugLogCount === SHOW_IMAGE_DEBUG_MAX_LOGS) {
|
||
console.warn('[SHOW_IMAGE_DEBUG]', 'log-limit-reached', {
|
||
max: SHOW_IMAGE_DEBUG_MAX_LOGS,
|
||
version: SHOW_IMAGE_DEBUG_VERSION
|
||
});
|
||
return;
|
||
}
|
||
console.log('[SHOW_IMAGE_DEBUG]', event, payload);
|
||
}
|
||
|
||
function isShowHtmlDebugEnabled() {
|
||
if (typeof window === 'undefined') return true;
|
||
try {
|
||
const explicitFlag = (window as any).__SHOW_HTML_DEBUG__;
|
||
if (explicitFlag === false || explicitFlag === '0') return false;
|
||
if (explicitFlag === true || explicitFlag === '1') return true;
|
||
const localFlag = window.localStorage?.getItem('showHtmlDebug');
|
||
if (localFlag === '0' || localFlag === 'false') return false;
|
||
if (localFlag === '1' || localFlag === 'true') return true;
|
||
return true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function debugShowHtmlLog(event: string, payload: Record<string, any> = {}) {
|
||
if (!isShowHtmlDebugEnabled()) return;
|
||
if (showHtmlDebugLogCount >= SHOW_HTML_DEBUG_MAX_LOGS) return;
|
||
showHtmlDebugLogCount += 1;
|
||
if (showHtmlDebugLogCount === 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);
|
||
}
|
||
|
||
function markShowTagDrawingActive(holdMs = 1600) {
|
||
if (typeof window === 'undefined') return;
|
||
try {
|
||
(window as any).__SHOW_TAG_DRAWING_UNTIL__ = Date.now() + holdMs;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function summarizeNodeForDebug(node: Node | null) {
|
||
if (!node) return 'null';
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
const text = (node.textContent || '').replace(/\s+/g, ' ').trim();
|
||
return `TEXT(${text.slice(0, 80)})`;
|
||
}
|
||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||
const el = node as Element;
|
||
const tag = el.tagName.toLowerCase();
|
||
if (tag === 'show_image' || tag === 'show-image') {
|
||
return `SHOW_IMAGE(src=${el.getAttribute('src') || ''})`;
|
||
}
|
||
return `${tag.toUpperCase()}`;
|
||
}
|
||
return `NODE(${node.nodeType})`;
|
||
}
|
||
|
||
function collectShowImageOrder(root: ParentNode | null = document) {
|
||
if (!root || !(root instanceof Node)) return [];
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ALL);
|
||
const tokens: string[] = [];
|
||
let current = walker.currentNode;
|
||
while (current) {
|
||
if (current.nodeType === Node.TEXT_NODE) {
|
||
const text = (current.textContent || '').replace(/\s+/g, ' ').trim();
|
||
if (text) tokens.push(`TEXT(${text.slice(0, 40)})`);
|
||
} else if (current.nodeType === Node.ELEMENT_NODE) {
|
||
const el = current as Element;
|
||
const tag = el.tagName.toLowerCase();
|
||
if (tag === 'show_image' || tag === 'show-image') {
|
||
tokens.push(`SHOW_IMAGE(${el.getAttribute('src') || ''})`);
|
||
} else if (tag === 'show_html' || tag === 'show-html') {
|
||
tokens.push(`SHOW_HTML(ratio=${readShowHtmlRatio(el)},js=${readShowHtmlJsMode(el)})`);
|
||
}
|
||
}
|
||
current = walker.nextNode();
|
||
}
|
||
return tokens.slice(0, 200);
|
||
}
|
||
|
||
function normalizeShowImageSrc(src: string) {
|
||
if (!src) return '';
|
||
const trimmed = src.trim();
|
||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||
if (trimmed.startsWith('/user_upload/')) return trimmed;
|
||
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
|
||
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
|
||
if (idx >= 0) {
|
||
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
|
||
}
|
||
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||
return trimmed;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function parsePixelSize(raw: string | null, fallback: number) {
|
||
if (!raw) return fallback;
|
||
const n = Number.parseInt(raw.trim(), 10);
|
||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||
return Math.min(4096, n);
|
||
}
|
||
|
||
const SHOW_HTML_ALLOWED_RATIOS = ['1:1', '16:9', '9:16', '4:3', '3:4'] as const;
|
||
const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
|
||
'1:1': 1,
|
||
'16:9': 16 / 9,
|
||
'9:16': 9 / 16,
|
||
'4:3': 4 / 3,
|
||
'3:4': 3 / 4
|
||
};
|
||
// show_html 统一按比例对应预设基准像素渲染(前端仍会按可用空间自适应)
|
||
const SHOW_HTML_RATIO_BASE_SIZE: Record<string, { width: number; height: number }> = {
|
||
'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) {
|
||
if (!raw) return '1:1';
|
||
const cleaned = raw.trim().toLowerCase().replace(/[x/]/g, ':').replace(/\s+/g, '');
|
||
if ((SHOW_HTML_ALLOWED_RATIOS as readonly string[]).includes(cleaned)) return cleaned;
|
||
return '1:1';
|
||
}
|
||
|
||
function pickNearestAllowedRatio(width: number, height: number) {
|
||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||
return '1:1';
|
||
}
|
||
const target = width / height;
|
||
let best = '1:1';
|
||
let bestDiff = Number.POSITIVE_INFINITY;
|
||
for (const ratio of SHOW_HTML_ALLOWED_RATIOS) {
|
||
const diff = Math.abs(SHOW_HTML_RATIO_VALUES[ratio] - target);
|
||
if (diff < bestDiff) {
|
||
bestDiff = diff;
|
||
best = ratio;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function readShowHtmlRatio(node: Element) {
|
||
const ratioAttr = node.getAttribute('ratio');
|
||
if (ratioAttr) return normalizeShowHtmlRatio(ratioAttr);
|
||
// 兼容旧格式:模型输出了 width/height 时,自动映射到允许的比例
|
||
const width = parsePixelSize(node.getAttribute('width'), 0);
|
||
const height = parsePixelSize(node.getAttribute('height'), 0);
|
||
if (width > 0 && height > 0) return pickNearestAllowedRatio(width, height);
|
||
return '1:1';
|
||
}
|
||
|
||
function normalizeShowHtmlJsMode(raw: string | null) {
|
||
if (!raw) return 'off';
|
||
const cleaned = raw.trim().toLowerCase();
|
||
if (['on', '1', 'true', 'yes', 'enabled', 'enable'].includes(cleaned)) {
|
||
return 'on';
|
||
}
|
||
return 'off';
|
||
}
|
||
|
||
function readShowHtmlJsMode(node: Element) {
|
||
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) {
|
||
if (!input) return '';
|
||
try {
|
||
return decodeURIComponent(escape(atob(input)));
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function sanitizeInlineCss(css: string) {
|
||
if (!css) return '';
|
||
return css
|
||
.replace(/expression\s*\([^)]*\)/gi, '')
|
||
.replace(/url\s*\(\s*['"]?\s*javascript:[^)]+\)/gi, 'url(about:blank)')
|
||
.replace(/@import[^;]+;/gi, '');
|
||
}
|
||
|
||
function rewriteCssSelectorsForShadowDom(css: string) {
|
||
if (!css) return '';
|
||
// 注意:不能直接把 html/body 连续替换成 .show-html-root,
|
||
// 否则后续 /\bhtml\b/ 会再次命中 ".show-html-root" 里的 "html",产生
|
||
// 类似 ".show-.show-html-root-root" 的错误选择器,导致布局样式失效(元素偏移)。
|
||
const placeholder = '__SHOW_HTML_ROOT_SELECTOR__';
|
||
return css
|
||
.replace(/\bhtml\s*,\s*body\b/gi, placeholder)
|
||
.replace(/\bbody\s*,\s*html\b/gi, placeholder)
|
||
.replace(/\bhtml\b/gi, placeholder)
|
||
.replace(/\bbody\b/gi, placeholder)
|
||
.replace(new RegExp(placeholder, 'g'), '.show-html-root');
|
||
}
|
||
|
||
function sanitizeShowHtmlContent(rawHtml: string) {
|
||
const purified = DOMPurify.sanitize(rawHtml || '', {
|
||
USE_PROFILES: { html: true, svg: true, svgFilters: true, mathMl: true },
|
||
FORBID_TAGS: ['script', 'noscript', 'iframe', 'object', 'embed', 'link'],
|
||
ALLOW_DATA_ATTR: false,
|
||
ALLOW_ARIA_ATTR: true
|
||
}) as string;
|
||
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(`<body>${purified}</body>`, 'text/html');
|
||
|
||
const elements = Array.from(doc.body.querySelectorAll('*'));
|
||
elements.forEach((el) => {
|
||
Array.from(el.attributes).forEach((attr) => {
|
||
const name = attr.name.toLowerCase();
|
||
const value = attr.value || '';
|
||
if (name.startsWith('on')) {
|
||
el.removeAttribute(attr.name);
|
||
return;
|
||
}
|
||
if (
|
||
(name === 'src' || name === 'href' || name === 'xlink:href') &&
|
||
/^\s*javascript:/i.test(value)
|
||
) {
|
||
el.removeAttribute(attr.name);
|
||
return;
|
||
}
|
||
if (name === 'style') {
|
||
el.setAttribute('style', sanitizeInlineCss(value));
|
||
}
|
||
});
|
||
});
|
||
|
||
doc.querySelectorAll('style').forEach((styleEl) => {
|
||
styleEl.textContent = rewriteCssSelectorsForShadowDom(
|
||
sanitizeInlineCss(styleEl.textContent || '')
|
||
);
|
||
});
|
||
|
||
return doc.body.innerHTML;
|
||
}
|
||
|
||
function buildShowHtmlIframeSrcdoc(rawHtml: string) {
|
||
const content = (rawHtml || '').trim();
|
||
if (!content) {
|
||
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
||
}
|
||
|
||
// 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。
|
||
if (/<html[\s>]/i.test(content)) {
|
||
return content;
|
||
}
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<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) {
|
||
return input
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function renderShowImages(root: ParentNode | null = document) {
|
||
if (!root) return;
|
||
const debugMode = getShowImageDebugMode();
|
||
const verbose = debugMode === 'verbose';
|
||
const renderId = ++showImageRenderSeq;
|
||
const imageNodes = Array.from(
|
||
root.querySelectorAll('show_image:not([data-rendered]), show-image:not([data-rendered])')
|
||
);
|
||
const htmlNodes = Array.from(
|
||
root.querySelectorAll('show_html:not([data-rendered]), show-html:not([data-rendered])')
|
||
);
|
||
if (htmlNodes.length > 0) {
|
||
markShowTagDrawingActive();
|
||
}
|
||
if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0) return;
|
||
|
||
const rootNode = root as Node;
|
||
debugShowImageLog('render:start', {
|
||
renderId,
|
||
rootType:
|
||
rootNode?.nodeType === Node.ELEMENT_NODE
|
||
? (rootNode as Element).tagName.toLowerCase()
|
||
: `nodeType:${rootNode?.nodeType}`,
|
||
pendingImageCount: imageNodes.length,
|
||
pendingHtmlCount: htmlNodes.length,
|
||
orderBefore: collectShowImageOrder(root)
|
||
});
|
||
|
||
if (verbose) {
|
||
debugShowImageLog('render:pending-show-images', {
|
||
renderId,
|
||
count: imageNodes.length,
|
||
nodes: imageNodes.map((n) => n.getAttribute('src') || '')
|
||
});
|
||
}
|
||
imageNodes.forEach((node) => {
|
||
if (!node.isConnected || node.hasAttribute('data-rendered')) return;
|
||
|
||
if (verbose) {
|
||
debugShowImageLog('render:node-start', {
|
||
renderId,
|
||
src: node.getAttribute('src') || '',
|
||
parent: summarizeNodeForDebug(node.parentNode),
|
||
prevSibling: summarizeNodeForDebug(node.previousSibling),
|
||
nextSibling: summarizeNodeForDebug(node.nextSibling),
|
||
childCount: node.childNodes.length,
|
||
childPreview: Array.from(node.childNodes)
|
||
.slice(0, 6)
|
||
.map((child) => summarizeNodeForDebug(child))
|
||
});
|
||
}
|
||
|
||
// 将 show_image 内误被包裹的内容移动到当前节点之后,保持原有顺序
|
||
if (node.parentNode && node.firstChild) {
|
||
const parent = node.parentNode;
|
||
const ref = node.nextSibling; // 可能为 null,insertBefore 会当 append
|
||
const children = Array.from(node.childNodes);
|
||
if (verbose) {
|
||
debugShowImageLog('render:unwrap-children', {
|
||
renderId,
|
||
src: node.getAttribute('src') || '',
|
||
moveCount: children.length,
|
||
ref: summarizeNodeForDebug(ref),
|
||
children: children.slice(0, 10).map((child) => summarizeNodeForDebug(child))
|
||
});
|
||
}
|
||
children.forEach((child) => parent.insertBefore(child, ref));
|
||
}
|
||
|
||
const rawSrc = node.getAttribute('src') || '';
|
||
const mappedSrc = normalizeShowImageSrc(rawSrc);
|
||
if (!mappedSrc) {
|
||
node.setAttribute('data-rendered', '1');
|
||
node.setAttribute('data-rendered-error', 'invalid-src');
|
||
if (verbose) debugShowImageLog('render:invalid-src', { renderId, rawSrc });
|
||
return;
|
||
}
|
||
const alt = node.getAttribute('alt') || '';
|
||
const safeAlt = escapeHtml(alt.trim());
|
||
const figure = document.createElement('figure');
|
||
figure.className = 'chat-inline-card chat-inline-card--image chat-inline-image';
|
||
|
||
const img = document.createElement('img');
|
||
img.loading = 'lazy';
|
||
img.src = mappedSrc;
|
||
img.alt = safeAlt;
|
||
img.onerror = () => {
|
||
figure.classList.add('chat-inline-card--error', 'chat-inline-image--error');
|
||
const tip = document.createElement('div');
|
||
tip.className = 'chat-inline-card__error chat-inline-image__error';
|
||
tip.textContent = '图片加载失败';
|
||
figure.appendChild(tip);
|
||
};
|
||
figure.appendChild(img);
|
||
|
||
if (safeAlt) {
|
||
const caption = document.createElement('figcaption');
|
||
caption.className = 'chat-inline-card__caption';
|
||
caption.innerHTML = safeAlt;
|
||
figure.appendChild(caption);
|
||
}
|
||
|
||
node.replaceChildren(figure);
|
||
node.setAttribute('data-rendered', '1');
|
||
if (verbose) {
|
||
debugShowImageLog('render:node-done', {
|
||
renderId,
|
||
rawSrc,
|
||
mappedSrc,
|
||
alt
|
||
});
|
||
}
|
||
});
|
||
|
||
htmlNodes.forEach((node) => {
|
||
markShowTagDrawingActive();
|
||
if (!node.isConnected) return;
|
||
const inStreaming = !!node.closest('.streaming-text');
|
||
const pathKey = buildNodePathKey(node);
|
||
const isPartial = node.getAttribute('data-partial') === '1';
|
||
const encoded = node.getAttribute('data-encoded') || '';
|
||
const jsMode = readShowHtmlJsMode(node);
|
||
const computedRatioKey = readShowHtmlRatio(node);
|
||
let ratioKey = computedRatioKey;
|
||
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
|
||
|
||
// 流式阶段:一旦 show_html 已经出现完整闭合(非 partial),冻结该 path 的已完成快照;
|
||
// 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。
|
||
if (inStreaming && !isPartial && encoded) {
|
||
const frozen = showHtmlCompletedSnapshotByPath.get(pathKey);
|
||
if (!frozen || frozen.encoded !== encoded || frozen.ratioKey !== ratioKey) {
|
||
showHtmlCompletedSnapshotByPath.set(pathKey, {
|
||
encoded,
|
||
ratioKey
|
||
});
|
||
}
|
||
} else if (!inStreaming) {
|
||
showHtmlCompletedSnapshotByPath.delete(pathKey);
|
||
}
|
||
const frozenSnapshot = inStreaming ? showHtmlCompletedSnapshotByPath.get(pathKey) : null;
|
||
const effectiveEncoded = frozenSnapshot?.encoded || encoded;
|
||
if (frozenSnapshot?.ratioKey) {
|
||
ratioKey = frozenSnapshot.ratioKey;
|
||
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
|
||
}
|
||
|
||
if (inStreaming) {
|
||
const locked = showHtmlStreamingSizeLockByPath.get(pathKey);
|
||
if (locked) {
|
||
// 修复:流式初期若 ratio 解析不完整会先落到 1:1,后续必须允许升级到真实比例
|
||
const shouldUpgradeRatio =
|
||
ratioKey !== locked.ratioKey && (locked.ratioKey === '1:1' || ratioKey !== '1:1');
|
||
if (shouldUpgradeRatio) {
|
||
ratioKey = computedRatioKey;
|
||
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
|
||
showHtmlStreamingSizeLockByPath.set(pathKey, {
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx
|
||
});
|
||
} else {
|
||
ratioKey = locked.ratioKey;
|
||
widthPx = locked.widthPx;
|
||
heightPx = locked.heightPx;
|
||
}
|
||
} else {
|
||
showHtmlStreamingSizeLockByPath.set(pathKey, {
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx
|
||
});
|
||
}
|
||
} else {
|
||
showHtmlStreamingSizeLockByPath.delete(pathKey);
|
||
}
|
||
|
||
// streaming 阶段 v-html 会反复重建 show_html 节点,这里先锁定占位尺寸,避免高度掉 0
|
||
node.style.display = 'block';
|
||
node.style.width = `${widthPx}px`;
|
||
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', {
|
||
renderId,
|
||
inStreaming,
|
||
pathKey,
|
||
hasRendered: node.hasAttribute('data-rendered'),
|
||
ratioAttr: node.getAttribute('ratio') || '',
|
||
jsMode,
|
||
ratioKey,
|
||
partial: node.getAttribute('data-partial') || '',
|
||
encodedLength: encoded.length,
|
||
effectiveEncodedLength: effectiveEncoded.length,
|
||
innerLength: (node.innerHTML || '').length,
|
||
reservedWidth: `${widthPx}px`,
|
||
reservedMinHeight: `${heightPx}px`
|
||
});
|
||
|
||
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
|
||
if (jsMode === 'on' && isPartial) {
|
||
let pending = showHtmlJsPendingRenderByPath.get(pathKey);
|
||
if (!pending) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className =
|
||
'chat-inline-card chat-inline-card--html chat-inline-card--pending chat-inline-html chat-inline-html--pending';
|
||
const host = document.createElement('div');
|
||
host.className = 'chat-inline-card__body chat-inline-html__host chat-inline-html__host--pending';
|
||
const tip = document.createElement('div');
|
||
tip.className = 'chat-inline-card__pending-tip chat-inline-html__pending-tip';
|
||
tip.textContent = '正在渲染内容...';
|
||
host.appendChild(tip);
|
||
wrapper.appendChild(host);
|
||
pending = {
|
||
wrapper,
|
||
host,
|
||
tip,
|
||
widthPx: 0,
|
||
heightPx: 0,
|
||
ratioKey: '1:1'
|
||
};
|
||
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.setAttribute('data-rendered', '1');
|
||
debugShowHtmlLog('render:node-js-pending', {
|
||
renderId,
|
||
pathKey,
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx
|
||
});
|
||
return;
|
||
}
|
||
|
||
// js="on":闭合后使用 sandbox iframe 隔离执行(不依赖 shadow root 直渲染)。
|
||
if (jsMode === 'on') {
|
||
const rawHtml = effectiveEncoded ? decodeBase64Utf8(effectiveEncoded) : node.innerHTML || '';
|
||
const srcdoc = buildShowHtmlIframeSrcdoc(rawHtml);
|
||
let persistent = showHtmlJsIframeRenderByPath.get(pathKey);
|
||
if (!persistent) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className =
|
||
'chat-inline-card chat-inline-card--html chat-inline-card--iframe chat-inline-html chat-inline-html--iframe';
|
||
const iframe = document.createElement('iframe');
|
||
iframe.className = 'chat-inline-html__iframe';
|
||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||
iframe.setAttribute('referrerpolicy', 'no-referrer');
|
||
iframe.setAttribute('loading', 'lazy');
|
||
wrapper.appendChild(iframe);
|
||
persistent = {
|
||
encoded: '',
|
||
ratioKey: '1:1',
|
||
widthPx: 0,
|
||
heightPx: 0,
|
||
srcdoc: '',
|
||
wrapper,
|
||
iframe
|
||
};
|
||
showHtmlJsIframeRenderByPath.set(pathKey, persistent);
|
||
}
|
||
persistent.wrapper.style.width = `${widthPx}px`;
|
||
persistent.iframe.style.height = `${heightPx}px`;
|
||
const needsUpdate =
|
||
persistent.encoded !== effectiveEncoded ||
|
||
persistent.widthPx !== widthPx ||
|
||
persistent.heightPx !== heightPx ||
|
||
persistent.ratioKey !== ratioKey ||
|
||
persistent.srcdoc !== srcdoc;
|
||
if (needsUpdate) {
|
||
persistent.iframe.srcdoc = srcdoc;
|
||
persistent.encoded = effectiveEncoded;
|
||
persistent.widthPx = widthPx;
|
||
persistent.heightPx = heightPx;
|
||
persistent.ratioKey = ratioKey;
|
||
persistent.srcdoc = srcdoc;
|
||
}
|
||
if (!inStreaming || isPartial || needsUpdate || !node.hasAttribute('data-rendered')) {
|
||
node.replaceChildren(persistent.wrapper);
|
||
}
|
||
node.setAttribute('data-rendered', '1');
|
||
showHtmlJsPendingRenderByPath.delete(pathKey);
|
||
debugShowHtmlLog('render:node-js-iframe', {
|
||
renderId,
|
||
pathKey,
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx,
|
||
encodedLength: effectiveEncoded.length,
|
||
srcdocLength: srcdoc.length,
|
||
needsUpdate
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (inStreaming) {
|
||
const now = Date.now();
|
||
const lastTs = showHtmlStreamingRenderTsByPath.get(pathKey) || 0;
|
||
if (now - lastTs < SHOW_HTML_STREAMING_RENDER_INTERVAL_MS) {
|
||
const persistent = showHtmlPersistentRenderByPath.get(pathKey);
|
||
if (persistent) {
|
||
persistent.wrapper.style.width = `${widthPx}px`;
|
||
persistent.host.style.height = `${heightPx}px`;
|
||
node.replaceChildren(persistent.wrapper);
|
||
node.setAttribute('data-rendered', '1');
|
||
debugShowHtmlLog('render:node-throttled-rehydrate', {
|
||
renderId,
|
||
pathKey,
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx
|
||
});
|
||
}
|
||
debugShowHtmlLog('render:node-throttled', {
|
||
renderId,
|
||
pathKey,
|
||
now,
|
||
lastTs,
|
||
interval: SHOW_HTML_STREAMING_RENDER_INTERVAL_MS
|
||
});
|
||
return;
|
||
}
|
||
showHtmlStreamingRenderTsByPath.set(pathKey, now);
|
||
} else {
|
||
showHtmlStreamingRenderTsByPath.delete(pathKey);
|
||
}
|
||
|
||
const rawHtml = effectiveEncoded ? decodeBase64Utf8(effectiveEncoded) : node.innerHTML || '';
|
||
const safeHtml = sanitizeShowHtmlContent(rawHtml);
|
||
debugShowHtmlLog('render:node-content', {
|
||
renderId,
|
||
pathKey,
|
||
jsMode,
|
||
encodedLength: encoded.length,
|
||
effectiveEncodedLength: effectiveEncoded.length,
|
||
rawLength: rawHtml.length,
|
||
safeLength: safeHtml.length,
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx
|
||
});
|
||
|
||
let persistent = showHtmlPersistentRenderByPath.get(pathKey);
|
||
if (!persistent && effectiveEncoded && inStreaming) {
|
||
// 精确匹配
|
||
let ep = showHtmlPersistentByEncoded.get(effectiveEncoded);
|
||
// 前缀匹配:多条渲染源可能输出不同阶段的 encoded,用最长前缀匹配找到 COMPLETE 的 wrapper
|
||
if (!ep) {
|
||
let bestLen = 0;
|
||
for (const [key, val] of showHtmlPersistentByEncoded) {
|
||
if (key.startsWith(effectiveEncoded) && key.length > bestLen) {
|
||
ep = val; bestLen = key.length;
|
||
}
|
||
}
|
||
}
|
||
if (ep) {
|
||
persistent = { encoded: ep.encoded, ratioKey: ep.ratioKey, widthPx: ep.widthPx, heightPx: ep.heightPx, wrapper: ep.wrapper, host: ep.host, root: ep.root };
|
||
showHtmlPersistentRenderByPath.set(pathKey, persistent);
|
||
}
|
||
}
|
||
if (!persistent) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'chat-inline-card chat-inline-card--html chat-inline-html';
|
||
const host = document.createElement('div');
|
||
host.className = 'chat-inline-card__body chat-inline-html__host';
|
||
const shadow = host.attachShadow({ mode: 'open' });
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
:host {
|
||
display: block;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: auto;
|
||
background: transparent;
|
||
color: inherit;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
*, *::before, *::after {
|
||
box-sizing: border-box;
|
||
}
|
||
.show-html-root {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 100%;
|
||
overflow: auto;
|
||
background: transparent;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
|
||
}
|
||
.show-html-root::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
.show-html-root::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.show-html-root::-webkit-scrollbar-thumb {
|
||
background: rgba(121, 109, 94, 0.48);
|
||
border-radius: 8px;
|
||
}
|
||
.show-html-root::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(121, 109, 94, 0.62);
|
||
}
|
||
`;
|
||
const root = document.createElement('div');
|
||
root.className = 'show-html-root';
|
||
shadow.appendChild(style);
|
||
shadow.appendChild(root);
|
||
wrapper.appendChild(host);
|
||
persistent = {
|
||
encoded: '',
|
||
ratioKey: '1:1',
|
||
widthPx: 0,
|
||
heightPx: 0,
|
||
wrapper,
|
||
host,
|
||
root
|
||
};
|
||
showHtmlPersistentRenderByPath.set(pathKey, persistent);
|
||
}
|
||
|
||
persistent.wrapper.style.width = `${widthPx}px`;
|
||
persistent.host.style.height = `${heightPx}px`;
|
||
const needsUpdate =
|
||
persistent.encoded !== effectiveEncoded ||
|
||
persistent.widthPx !== widthPx ||
|
||
persistent.heightPx !== heightPx ||
|
||
persistent.ratioKey !== ratioKey;
|
||
if (needsUpdate) {
|
||
persistent.root.innerHTML = safeHtml;
|
||
persistent.encoded = effectiveEncoded;
|
||
persistent.widthPx = widthPx;
|
||
persistent.heightPx = heightPx;
|
||
persistent.ratioKey = ratioKey;
|
||
if (effectiveEncoded && inStreaming) {
|
||
showHtmlPersistentByEncoded.set(effectiveEncoded, {
|
||
wrapper: persistent.wrapper, host: persistent.host, root: persistent.root,
|
||
encoded: effectiveEncoded, ratioKey, widthPx, heightPx
|
||
});
|
||
}
|
||
debugShowHtmlLog('render:node-persistent-update', {
|
||
renderId,
|
||
pathKey,
|
||
encodedLength: effectiveEncoded.length,
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx
|
||
});
|
||
} else {
|
||
debugShowHtmlLog('render:node-persistent-reuse', {
|
||
renderId,
|
||
pathKey,
|
||
encodedLength: effectiveEncoded.length
|
||
});
|
||
}
|
||
|
||
if (!inStreaming || isPartial || needsUpdate || !node.hasAttribute('data-rendered')) {
|
||
node.replaceChildren(persistent.wrapper);
|
||
}
|
||
node.setAttribute('data-rendered', '1');
|
||
const rect = persistent.wrapper.getBoundingClientRect();
|
||
debugShowHtmlLog('render:node-done', {
|
||
renderId,
|
||
pathKey,
|
||
jsMode,
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx,
|
||
rect: {
|
||
x: Math.round(rect.x),
|
||
y: Math.round(rect.y),
|
||
w: Math.round(rect.width),
|
||
h: Math.round(rect.height)
|
||
},
|
||
partial: node.getAttribute('data-partial') || '',
|
||
inStreaming
|
||
});
|
||
|
||
if (verbose) {
|
||
debugShowImageLog('render:show-html-done', {
|
||
renderId,
|
||
ratioKey,
|
||
widthPx,
|
||
heightPx,
|
||
contentLength: rawHtml.length
|
||
});
|
||
}
|
||
});
|
||
|
||
debugShowImageLog('render:end', {
|
||
renderId,
|
||
orderAfter: collectShowImageOrder(root)
|
||
});
|
||
}
|
||
|
||
let showImageObserver: MutationObserver | null = null;
|
||
let showContainerObserver: MutationObserver | null = null;
|
||
let showTagRenderScheduled = false;
|
||
let showTagBindRetryTimer: number | null = null;
|
||
let showTagObservedContainer: Element | null = null;
|
||
const showHtmlStreamingRenderTsByPath = new Map<string, number>();
|
||
const SHOW_HTML_STREAMING_RENDER_INTERVAL_MS = 180;
|
||
const showHtmlCompletedSnapshotByPath = new Map<
|
||
string,
|
||
{
|
||
encoded: string,
|
||
ratioKey: string
|
||
}
|
||
>();
|
||
const showHtmlStreamingSizeLockByPath = new Map<
|
||
string,
|
||
{
|
||
ratioKey: string,
|
||
widthPx: number,
|
||
heightPx: number
|
||
}
|
||
>();
|
||
const showHtmlPersistentRenderByPath = new Map<
|
||
string,
|
||
{
|
||
encoded: string,
|
||
ratioKey: string,
|
||
widthPx: number,
|
||
heightPx: number,
|
||
wrapper: HTMLElement,
|
||
host: HTMLElement,
|
||
root: HTMLElement
|
||
}
|
||
>();
|
||
const showHtmlPersistentByEncoded = new Map<string, {
|
||
wrapper: HTMLElement, host: HTMLElement, root: HTMLElement,
|
||
encoded: string, ratioKey: string, widthPx: number, heightPx: number
|
||
}>();
|
||
const showHtmlJsPendingRenderByPath = new Map<
|
||
string,
|
||
{
|
||
wrapper: HTMLElement,
|
||
host: HTMLElement,
|
||
tip: HTMLElement,
|
||
widthPx: number,
|
||
heightPx: number,
|
||
ratioKey: string
|
||
}
|
||
>();
|
||
const showHtmlJsIframeRenderByPath = new Map<
|
||
string,
|
||
{
|
||
encoded: string,
|
||
ratioKey: string,
|
||
widthPx: number,
|
||
heightPx: number,
|
||
srcdoc: string,
|
||
wrapper: HTMLElement,
|
||
iframe: HTMLIFrameElement
|
||
}
|
||
>();
|
||
let layoutDebugObserver: MutationObserver | null = null;
|
||
let layoutDebugResizeObserver: ResizeObserver | null = null;
|
||
let layoutDebugStarted = false;
|
||
let layoutDebugBindRetryTimer: number | null = null;
|
||
let layoutDebugCount = 0;
|
||
const LAYOUT_DEBUG_MAX = 300;
|
||
const layoutDebugLastTsByKey = new Map<string, number>();
|
||
|
||
function scheduleShowTagRender(container: Element) {
|
||
if (showTagRenderScheduled) return;
|
||
showTagRenderScheduled = true;
|
||
const run = () => {
|
||
showTagRenderScheduled = false;
|
||
renderShowImages(container);
|
||
};
|
||
if (typeof queueMicrotask === 'function') {
|
||
queueMicrotask(run);
|
||
return;
|
||
}
|
||
Promise.resolve().then(run);
|
||
}
|
||
|
||
function getShowTagContainer() {
|
||
return document.querySelector('.messages-area');
|
||
}
|
||
|
||
function isLayoutDebugEnabled() {
|
||
if (typeof window === 'undefined') return true;
|
||
try {
|
||
const explicitFlag = (window as any).__LAYOUT_DEBUG__;
|
||
if (explicitFlag === false || explicitFlag === '0') return false;
|
||
if (explicitFlag === true || explicitFlag === '1') return true;
|
||
const localFlag = window.localStorage?.getItem('layoutDebug');
|
||
if (localFlag === '0' || localFlag === 'false') return false;
|
||
if (localFlag === '1' || localFlag === 'true') return true;
|
||
return true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function layoutDebugLog(event: string, payload: Record<string, any> = {}, key = event) {
|
||
if (!isLayoutDebugEnabled()) return;
|
||
if (layoutDebugCount >= LAYOUT_DEBUG_MAX) return;
|
||
const now = Date.now();
|
||
const lastTs = layoutDebugLastTsByKey.get(key) || 0;
|
||
if (now - lastTs < 120) return;
|
||
layoutDebugLastTsByKey.set(key, now);
|
||
layoutDebugCount += 1;
|
||
if (layoutDebugCount === LAYOUT_DEBUG_MAX) {
|
||
console.warn('[LAYOUT_DEBUG]', 'log-limit-reached', { max: LAYOUT_DEBUG_MAX });
|
||
return;
|
||
}
|
||
console.log('[LAYOUT_DEBUG]', event, payload);
|
||
}
|
||
|
||
function elementRectSummary(el: Element | null) {
|
||
if (!el) return null;
|
||
const rect = el.getBoundingClientRect();
|
||
return {
|
||
className: (el as HTMLElement).className || '',
|
||
x: Math.round(rect.x),
|
||
y: Math.round(rect.y),
|
||
width: Math.round(rect.width),
|
||
height: Math.round(rect.height)
|
||
};
|
||
}
|
||
|
||
function setupLayoutDebugObservers() {
|
||
if (layoutDebugStarted || !isLayoutDebugEnabled()) return;
|
||
const trackedSelectors = [
|
||
'.main-container',
|
||
'.workspace-region',
|
||
'.workspace-panel',
|
||
'.chat-container',
|
||
'.messages-area'
|
||
];
|
||
const tracked = trackedSelectors
|
||
.map((selector) => document.querySelector(selector))
|
||
.filter((el): el is Element => !!el);
|
||
if (tracked.length === 0) {
|
||
layoutDebugLog('layout:setup-wait', { reason: 'targets-not-mounted' }, 'layout:setup-wait');
|
||
if (layoutDebugBindRetryTimer) {
|
||
window.clearTimeout(layoutDebugBindRetryTimer);
|
||
}
|
||
layoutDebugBindRetryTimer = window.setTimeout(() => {
|
||
layoutDebugBindRetryTimer = null;
|
||
setupLayoutDebugObservers();
|
||
}, 250);
|
||
return;
|
||
}
|
||
layoutDebugStarted = true;
|
||
layoutDebugLog('layout:setup', {
|
||
trackedCount: tracked.length,
|
||
tracked: tracked.map((el) => (el as HTMLElement).className || el.tagName.toLowerCase())
|
||
});
|
||
|
||
const snapshot = () => {
|
||
layoutDebugLog(
|
||
'layout:snapshot',
|
||
{
|
||
window: {
|
||
innerWidth: window.innerWidth,
|
||
innerHeight: window.innerHeight
|
||
},
|
||
main: elementRectSummary(document.querySelector('.main-container')),
|
||
workspace: elementRectSummary(document.querySelector('.workspace-region')),
|
||
panel: elementRectSummary(document.querySelector('.workspace-panel')),
|
||
chat: elementRectSummary(document.querySelector('.chat-container')),
|
||
messages: elementRectSummary(document.querySelector('.messages-area'))
|
||
},
|
||
'layout:snapshot'
|
||
);
|
||
};
|
||
snapshot();
|
||
|
||
layoutDebugObserver = new MutationObserver((mutations) => {
|
||
const interesting = mutations.filter((m) => {
|
||
if (!(m.target instanceof Element)) return false;
|
||
const className = (m.target as HTMLElement).className || '';
|
||
return (
|
||
m.type === 'attributes' &&
|
||
(tracked.some((el) => el === m.target) ||
|
||
className.includes('chat-container') ||
|
||
className.includes('workspace-panel') ||
|
||
className.includes('main-container'))
|
||
);
|
||
});
|
||
if (!interesting.length) return;
|
||
layoutDebugLog('layout:mutation', {
|
||
count: interesting.length,
|
||
items: interesting.slice(0, 8).map((m) => ({
|
||
attr: m.attributeName,
|
||
target: summarizeNodeForDebug(m.target)
|
||
}))
|
||
});
|
||
snapshot();
|
||
});
|
||
layoutDebugObserver.observe(document.body, {
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ['class', 'style']
|
||
});
|
||
|
||
layoutDebugResizeObserver = new ResizeObserver((entries) => {
|
||
if (!entries.length) return;
|
||
layoutDebugLog('layout:resize', {
|
||
items: entries.slice(0, 8).map((entry) => ({
|
||
target: summarizeNodeForDebug(entry.target),
|
||
width: Math.round(entry.contentRect.width),
|
||
height: Math.round(entry.contentRect.height)
|
||
}))
|
||
});
|
||
snapshot();
|
||
});
|
||
tracked.forEach((el) => layoutDebugResizeObserver?.observe(el));
|
||
window.addEventListener('resize', snapshot);
|
||
}
|
||
|
||
function buildNodePathKey(node: Element) {
|
||
const getIndexWithin = (el: Element, selector: string) => {
|
||
const list = Array.from(document.querySelectorAll(selector));
|
||
const idx = list.indexOf(el);
|
||
return idx >= 0 ? idx : -1;
|
||
};
|
||
|
||
const parts: string[] = [];
|
||
const messageBlock = node.closest('.message-block');
|
||
if (messageBlock) {
|
||
parts.push(`msg:${getIndexWithin(messageBlock, '.message-block')}`);
|
||
}
|
||
const actionItem = node.closest('.action-item');
|
||
if (actionItem) {
|
||
parts.push(`action:${getIndexWithin(actionItem, '.action-item')}`);
|
||
}
|
||
const textContent = node.closest('.text-content');
|
||
if (textContent) {
|
||
parts.push(`text:${getIndexWithin(textContent, '.text-content')}`);
|
||
}
|
||
|
||
let cur: Element | null = node;
|
||
while (cur && cur !== document.body && parts.length < 12) {
|
||
const parent = cur.parentElement;
|
||
if (!parent) break;
|
||
const index = Array.prototype.indexOf.call(parent.children, cur);
|
||
parts.push(`${cur.tagName.toLowerCase()}:${index}`);
|
||
if (cur.classList.contains('text-content')) break;
|
||
cur = parent;
|
||
}
|
||
return parts.reverse().join('/');
|
||
}
|
||
|
||
export function setupShowImageObserver() {
|
||
if (showImageObserver || showContainerObserver) return;
|
||
setupLayoutDebugObservers();
|
||
const bind = () => {
|
||
const container = getShowTagContainer();
|
||
if (!container) {
|
||
if (showTagBindRetryTimer) {
|
||
window.clearTimeout(showTagBindRetryTimer);
|
||
}
|
||
showTagBindRetryTimer = window.setTimeout(bind, 250);
|
||
return;
|
||
}
|
||
if (showTagBindRetryTimer) {
|
||
window.clearTimeout(showTagBindRetryTimer);
|
||
showTagBindRetryTimer = null;
|
||
}
|
||
if (showTagObservedContainer === container && showImageObserver) return;
|
||
if (showImageObserver) {
|
||
showImageObserver.disconnect();
|
||
showImageObserver = null;
|
||
}
|
||
showTagObservedContainer = container;
|
||
|
||
debugShowImageLog('observer:setup', {
|
||
container: summarizeNodeForDebug(container),
|
||
debugMode: getShowImageDebugMode(),
|
||
version: SHOW_IMAGE_DEBUG_VERSION
|
||
});
|
||
renderShowImages(container);
|
||
showImageObserver = new MutationObserver((mutations) => {
|
||
const debugMode = getShowImageDebugMode();
|
||
const verbose = debugMode === 'verbose';
|
||
const hasShowImageRelatedMutation = mutations.some((mutation) => {
|
||
const targetIsShowImage =
|
||
mutation.target instanceof Element &&
|
||
['show_image', 'show_html', 'show-image', 'show-html'].includes(
|
||
mutation.target.tagName.toLowerCase()
|
||
);
|
||
const addedHasShowTag = Array.from(mutation.addedNodes).some(
|
||
(n) =>
|
||
n instanceof Element &&
|
||
(['show_image', 'show_html', 'show-image', 'show-html'].includes(
|
||
n.tagName.toLowerCase()
|
||
) || !!n.querySelector?.('show_image,show_html,show-image,show-html'))
|
||
);
|
||
return targetIsShowImage || addedHasShowTag;
|
||
});
|
||
if (!hasShowImageRelatedMutation) return;
|
||
debugShowImageLog('observer:show-image-mutation', {
|
||
mutationCount: mutations.length,
|
||
mutations: verbose
|
||
? mutations.slice(0, 20).map((m) => ({
|
||
type: m.type,
|
||
target: summarizeNodeForDebug(m.target),
|
||
added: Array.from(m.addedNodes)
|
||
.slice(0, 6)
|
||
.map((n) => summarizeNodeForDebug(n)),
|
||
removed: Array.from(m.removedNodes)
|
||
.slice(0, 6)
|
||
.map((n) => summarizeNodeForDebug(n))
|
||
}))
|
||
: undefined
|
||
});
|
||
scheduleShowTagRender(container);
|
||
});
|
||
showImageObserver.observe(container, { childList: true, subtree: true });
|
||
};
|
||
bind();
|
||
showContainerObserver = new MutationObserver(() => {
|
||
const latest = getShowTagContainer();
|
||
if (!latest) return;
|
||
if (latest !== showTagObservedContainer) {
|
||
bind();
|
||
}
|
||
});
|
||
showContainerObserver.observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
|
||
export function teardownShowImageObserver() {
|
||
if (showTagBindRetryTimer) {
|
||
window.clearTimeout(showTagBindRetryTimer);
|
||
showTagBindRetryTimer = null;
|
||
}
|
||
if (showImageObserver) {
|
||
showImageObserver.disconnect();
|
||
showImageObserver = null;
|
||
}
|
||
if (showContainerObserver) {
|
||
showContainerObserver.disconnect();
|
||
showContainerObserver = null;
|
||
}
|
||
if (layoutDebugObserver) {
|
||
layoutDebugObserver.disconnect();
|
||
layoutDebugObserver = null;
|
||
}
|
||
if (layoutDebugResizeObserver) {
|
||
layoutDebugResizeObserver.disconnect();
|
||
layoutDebugResizeObserver = null;
|
||
}
|
||
if (layoutDebugBindRetryTimer) {
|
||
window.clearTimeout(layoutDebugBindRetryTimer);
|
||
layoutDebugBindRetryTimer = null;
|
||
}
|
||
layoutDebugStarted = false;
|
||
layoutDebugCount = 0;
|
||
layoutDebugLastTsByKey.clear();
|
||
showHtmlStreamingRenderTsByPath.clear();
|
||
showHtmlCompletedSnapshotByPath.clear();
|
||
showHtmlStreamingSizeLockByPath.clear();
|
||
showHtmlPersistentRenderByPath.clear();
|
||
showHtmlPersistentByEncoded.clear();
|
||
showHtmlJsPendingRenderByPath.clear();
|
||
showHtmlJsIframeRenderByPath.clear();
|
||
showTagObservedContainer = null;
|
||
}
|
||
|
||
function updateViewportHeightVar() {
|
||
const docEl = document.documentElement;
|
||
const visualViewport = window.visualViewport;
|
||
|
||
if (visualViewport) {
|
||
const vh = visualViewport.height;
|
||
const bottomInset = Math.max(
|
||
0,
|
||
(window.innerHeight || docEl.clientHeight || vh) -
|
||
visualViewport.height -
|
||
visualViewport.offsetTop
|
||
);
|
||
docEl.style.setProperty('--app-viewport', `${vh}px`);
|
||
docEl.style.setProperty('--app-bottom-inset', `${bottomInset}px`);
|
||
} else {
|
||
const height = window.innerHeight || docEl.clientHeight;
|
||
if (height) {
|
||
docEl.style.setProperty('--app-viewport', `${height}px`);
|
||
}
|
||
docEl.style.setProperty('--app-bottom-inset', 'env(safe-area-inset-bottom, 0px)');
|
||
}
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.katex = katex;
|
||
|
||
updateViewportHeightVar();
|
||
window.addEventListener('resize', updateViewportHeightVar);
|
||
window.addEventListener('orientationchange', updateViewportHeightVar);
|
||
window.addEventListener('pageshow', updateViewportHeightVar);
|
||
if (window.visualViewport) {
|
||
window.visualViewport.addEventListener('resize', updateViewportHeightVar);
|
||
window.visualViewport.addEventListener('scroll', updateViewportHeightVar);
|
||
}
|
||
}
|