Files
PlantAI-rag-new/ui/src/components/chat/figureLayout.js
T
jerryandClaude Fable 5 bf136304d7 feat(chat): 图片 50% 宽布局 + 连续同比例双图并排
- 新增 figureLayout.js:sameAspect(容差2%)/pairFigures(贪心配对)/layoutTextRoot(幂等)
- AssistantMessage:图片 load 后读 naturalWidth/Height 重排,单图 half 类,双图包 figure-row
- 相邻判定兼容 HTML 解析器把 figure 提出 p 后留下的空 p
- CSS 修 :deep()(原 scoped 规则从未命中 v-html),单图 50%、row 内各 calc(50% - 7px) + 14px 间距
- 新测试 16 例,全量前端 174 例全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:47:37 +08:00

141 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// chat/figureLayout.js —— 聊天图片布局(2026-09-01)
// 需求:回答中的图片默认占页面宽度 50%;连续两张比例相同的图片并排一行各占 50%,留适当间距。
// 图片比例需读 naturalWidth/naturalHeight(img load 后才可用),因此布局在组件运行时用 JS 完成。
// 纯函数导出便于单测;DOM 操作仅在 layoutTextRoot 内。
/** 宽高比;w/h 无效(未加载)返回 null */
export function aspectOf(w, h) {
return w > 0 && h > 0 ? w / h : null;
}
/** 两个比例是否相同(相对误差 ≤ tol,默认 2%) */
export function sameAspect(a, b, tol = 0.02) {
if (a == null || b == null) return false;
return Math.abs(a - b) / Math.max(a, b) <= tol;
}
/** 空块元素(无子元素且无文本,如 HTML 解析器把 figure 提出 <p> 后留下的空 <p>)视为空白 */
const isEmptyBlock = (node) =>
node.nodeType === 1 && !node.querySelector('*') && node.textContent.trim() === '';
/** x、y 之间的兄弟节点是否只有空白文本/空块元素(无实体内容) */
export function betweenWhitespaceOnly(x, y) {
let node = x.nextSibling;
while (node && node !== y) {
if (node.nodeType === 3) {
if (node.textContent.trim() !== '') return false;
} else if (node.nodeType === 1) {
if (!isEmptyBlock(node)) return false;
} else {
return false;
}
node = node.nextSibling;
}
return node === y;
}
/**
* 两张 figure 是否「连续出现」:
* - 同一 <p> 内(行内图)仅空白相隔;或
* - 各自所在 <p> 是相邻兄弟且仅空白相隔(markdown-it 逐图成段的标准形态)。
*/
export function isInlineAdjacent(a, b) {
const ca = a.closest('p') || a;
const cb = b.closest('p') || b;
if (ca === cb) return betweenWhitespaceOnly(a, b);
if (!ca.parentElement || ca.parentElement !== cb.parentElement) return false;
return betweenWhitespaceOnly(ca, cb);
}
/**
* 从左到右贪心配对:相邻且同比例 → { figures:[a,b], row:true };
* 落单 → { figures:[f], row:false }。3 张连续同比例 = 1 对 + 1 单。
* @param {HTMLElement[]} figures figure.md-figure 元素(DOM 顺序)
* @param {(number|null)[]} aspects 与 figures 等长的宽高比数组
*/
export function pairFigures(figures, aspects) {
const groups = [];
let i = 0;
while (i < figures.length) {
if (i + 1 < figures.length && sameAspect(aspects[i], aspects[i + 1])) {
groups.push({ figures: [figures[i], figures[i + 1]], row: true });
i += 2;
} else {
groups.push({ figures: [figures[i]], row: false });
i += 1;
}
}
return groups;
}
/**
* 对一个 .md-text 根做布局(幂等,可反复调用):
* 1. 拆掉旧的 .figure-row 包裹、清 half 类;
* 2. 图片未加载完成(naturalWidth=0)→ 返回 pending img 列表,等 load 后重排;
* 3. 按「连续出现」切 run,run 内相邻同比例 → 包 .figure-row;落单 → figure 加 .half。
* @returns {{done: boolean, pending: HTMLImageElement[]}}
*/
export function layoutTextRoot(root) {
// 加载失败的图片由组件层隐藏(figure.style.display='none'),不参与布局
const figures = Array.from(root.querySelectorAll('.md-figure')).filter(
(f) => !(f instanceof HTMLElement && f.style.display === 'none')
);
// 幂等:拆旧包裹
for (const row of root.querySelectorAll('.figure-row')) {
const parent = row.parentElement;
while (row.firstChild) parent.insertBefore(row.firstChild, row);
parent.removeChild(row);
}
figures.forEach((f) => f.classList.remove('half'));
const aspects = figures.map((f) => {
const img = f.querySelector('img');
return img ? aspectOf(img.naturalWidth || 0, img.naturalHeight || 0) : null;
});
const pending = [];
figures.forEach((f, i) => {
if (aspects[i] == null) {
const img = f.querySelector('img');
if (img) pending.push(img);
}
});
if (pending.length) return { done: false, pending };
// 按连续性切 run
const runs = [];
let run = [];
for (const f of figures) {
if (run.length && !isInlineAdjacent(run[run.length - 1], f)) {
runs.push(run);
run = [];
}
run.push(f);
}
if (run.length) runs.push(run);
for (const r of runs) {
const groups = pairFigures(
r,
r.map((f) => {
const img = f.querySelector('img');
return aspectOf(img.naturalWidth, img.naturalHeight);
})
);
for (const g of groups) {
if (g.row) {
const row = document.createElement('div');
row.className = 'figure-row';
const parent = g.figures[0].parentElement;
parent.insertBefore(row, g.figures[0]);
row.appendChild(g.figures[0]);
row.appendChild(g.figures[1]);
} else {
g.figures[0].classList.add('half');
}
}
}
return { done: true, pending: [] };
}