- 新增 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>
141 lines
4.8 KiB
JavaScript
141 lines
4.8 KiB
JavaScript
// 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: [] };
|
||
}
|