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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
// figureLayout.test.js —— 聊天图片布局纯函数(2026-09-01)
|
||||
// 需求:单图 50% 宽;连续两张比例相同的图并排一行各 50%,留间距。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { aspectOf, sameAspect, pairFigures, isInlineAdjacent, layoutTextRoot } from '../../../../ui/src/components/chat/figureLayout.js';
|
||||
|
||||
const fig = () => document.createElement('figure');
|
||||
|
||||
describe('aspectOf / sameAspect', () => {
|
||||
it('未加载(0 值)返回 null', () => {
|
||||
expect(aspectOf(0, 0)).toBe(null);
|
||||
expect(aspectOf(0, 100)).toBe(null);
|
||||
expect(aspectOf(100, 0)).toBe(null);
|
||||
});
|
||||
it('宽高比计算', () => {
|
||||
expect(aspectOf(100, 50)).toBe(2);
|
||||
expect(aspectOf(800, 400)).toBe(2);
|
||||
});
|
||||
it('容差 2% 内判同比例', () => {
|
||||
expect(sameAspect(1, 1)).toBe(true);
|
||||
expect(sameAspect(1.0, 1.01)).toBe(true);
|
||||
expect(sameAspect(1.0, 1.03)).toBe(false);
|
||||
expect(sameAspect(1, null)).toBe(false);
|
||||
expect(sameAspect(null, null)).toBe(false);
|
||||
expect(sameAspect(4 / 3, 800 / 600)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pairFigures 贪心配对', () => {
|
||||
const figs = [fig(), fig(), fig(), fig()];
|
||||
it('相邻同比例 → 配对;不同 → 落单', () => {
|
||||
const g = pairFigures([figs[0], figs[1]], [1.5, 1.5]);
|
||||
expect(g).toHaveLength(1);
|
||||
expect(g[0].row).toBe(true);
|
||||
expect(g[0].figures).toEqual([figs[0], figs[1]]);
|
||||
const g2 = pairFigures([figs[0], figs[1]], [1.5, 1.0]);
|
||||
expect(g2).toHaveLength(2);
|
||||
expect(g2.every((x) => !x.row)).toBe(true);
|
||||
});
|
||||
it('3 张连续同比例 = 1 对 + 1 单', () => {
|
||||
const g = pairFigures([figs[0], figs[1], figs[2]], [1, 1, 1]);
|
||||
expect(g).toHaveLength(2);
|
||||
expect(g[0].row).toBe(true);
|
||||
expect(g[1].row).toBe(false);
|
||||
});
|
||||
it('空列表', () => {
|
||||
expect(pairFigures([], [])).toEqual([]);
|
||||
});
|
||||
it('比例未知(null)不配对', () => {
|
||||
const g = pairFigures([figs[0], figs[1]], [null, null]);
|
||||
expect(g).toHaveLength(2);
|
||||
expect(g.every((x) => !x.row)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInlineAdjacent 连续性', () => {
|
||||
it('同一 <p> 内仅空白相隔 → 相邻', () => {
|
||||
const p = document.createElement('p');
|
||||
const a = fig();
|
||||
const b = fig();
|
||||
p.append(a, document.createTextNode(' '), b);
|
||||
expect(isInlineAdjacent(a, b)).toBe(true);
|
||||
});
|
||||
it('相邻兄弟 <p> → 相邻(markdown-it 逐图成段)', () => {
|
||||
const wrap = document.createElement('div');
|
||||
const pa = document.createElement('p');
|
||||
const pb = document.createElement('p');
|
||||
pa.append(fig());
|
||||
pb.append(fig());
|
||||
wrap.append(pa, pb);
|
||||
expect(isInlineAdjacent(pa.firstChild, pb.firstChild)).toBe(true);
|
||||
});
|
||||
it('中间有文字段落 → 不相邻', () => {
|
||||
const wrap = document.createElement('div');
|
||||
const pa = document.createElement('p');
|
||||
const pm = document.createElement('p');
|
||||
const pb = document.createElement('p');
|
||||
pa.append(fig());
|
||||
pm.textContent = '说明文字';
|
||||
pb.append(fig());
|
||||
wrap.append(pa, pm, pb);
|
||||
expect(isInlineAdjacent(pa.firstChild, pb.firstChild)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutTextRoot 幂等布局', () => {
|
||||
function makeRoot(html) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'md-text';
|
||||
root.innerHTML = html;
|
||||
return root;
|
||||
}
|
||||
function readyImg(w, h) {
|
||||
const img = document.createElement('img');
|
||||
Object.defineProperty(img, 'naturalWidth', { value: w });
|
||||
Object.defineProperty(img, 'naturalHeight', { value: h });
|
||||
return img;
|
||||
}
|
||||
it('单图 → half 类,无 row', () => {
|
||||
const root = makeRoot('<p><figure class="md-figure"><img></figure></p>');
|
||||
root.querySelector('img').replaceWith(readyImg(800, 400));
|
||||
const r = layoutTextRoot(root);
|
||||
expect(r.done).toBe(true);
|
||||
expect(root.querySelectorAll('.figure-row').length).toBe(0);
|
||||
expect(root.querySelector('.md-figure').classList.contains('half')).toBe(true);
|
||||
});
|
||||
it('连续同比例双图 → 一个 figure-row,无 half', () => {
|
||||
const root = makeRoot(
|
||||
'<p><figure class="md-figure"><img></figure></p><p><figure class="md-figure"><img></figure></p>'
|
||||
);
|
||||
root.querySelectorAll('img').forEach((img) => img.replaceWith(readyImg(800, 400)));
|
||||
const r = layoutTextRoot(root);
|
||||
expect(r.done).toBe(true);
|
||||
expect(root.querySelectorAll('.figure-row').length).toBe(1);
|
||||
const row = root.querySelector('.figure-row');
|
||||
expect(row.children.length).toBe(2);
|
||||
expect(root.querySelectorAll('.md-figure.half').length).toBe(0);
|
||||
});
|
||||
it('比例不同的连续双图 → 各自 half,无 row', () => {
|
||||
const root = makeRoot(
|
||||
'<p><figure class="md-figure"><img></figure></p><p><figure class="md-figure"><img></figure></p>'
|
||||
);
|
||||
const imgs = root.querySelectorAll('img');
|
||||
imgs[0].replaceWith(readyImg(800, 400));
|
||||
imgs[1].replaceWith(readyImg(400, 800));
|
||||
const r = layoutTextRoot(root);
|
||||
expect(r.done).toBe(true);
|
||||
expect(root.querySelectorAll('.figure-row').length).toBe(0);
|
||||
expect(root.querySelectorAll('.md-figure.half').length).toBe(2);
|
||||
});
|
||||
it('图未加载 → 返回 pending,不布局', () => {
|
||||
const root = makeRoot('<p><figure class="md-figure"><img></figure></p>');
|
||||
const r = layoutTextRoot(root);
|
||||
expect(r.done).toBe(false);
|
||||
expect(r.pending.length).toBe(1);
|
||||
expect(root.querySelector('.md-figure').classList.contains('half')).toBe(false);
|
||||
});
|
||||
it('重复调用幂等(重排前拆旧 row / 清 half)', () => {
|
||||
const root = makeRoot(
|
||||
'<p><figure class="md-figure"><img></figure></p><p><figure class="md-figure"><img></figure></p>'
|
||||
);
|
||||
root.querySelectorAll('img').forEach((img) => img.replaceWith(readyImg(800, 400)));
|
||||
layoutTextRoot(root);
|
||||
expect(root.querySelectorAll('.figure-row').length).toBe(1);
|
||||
layoutTextRoot(root);
|
||||
expect(root.querySelectorAll('.figure-row').length).toBe(1);
|
||||
expect(root.querySelectorAll('.md-figure.half').length).toBe(0);
|
||||
});
|
||||
it('中间夹文字段落的双图 → 各自 half,无 row', () => {
|
||||
const root = makeRoot(
|
||||
'<p><figure class="md-figure"><img></figure></p><p>分隔文字</p><p><figure class="md-figure"><img></figure></p>'
|
||||
);
|
||||
root.querySelectorAll('img').forEach((img) => img.replaceWith(readyImg(800, 400)));
|
||||
const r = layoutTextRoot(root);
|
||||
expect(r.done).toBe(true);
|
||||
expect(root.querySelectorAll('.figure-row').length).toBe(0);
|
||||
expect(root.querySelectorAll('.md-figure.half').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,10 @@
|
||||
// 块类型:text(markdown-it + DOMPurify 消毒后 v-html)/ table(渐变表头 + 非「说明」列 mono)/
|
||||
// code / stream(光标 + shimmer-line,瞬态不落库)/ pipeline(五相位卡)/
|
||||
// tool(阶段卡)/ sources(内联引用 chip)/ graph(轻量摘要行,完整图在 bench kg tab)
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, onUpdated, onUnmounted, ref } from 'vue';
|
||||
import { T, BENCH_TABS } from '../../i18n/labels.js';
|
||||
import { sanitizeHtml, NON_MONO_HEADER } from './markdown.js';
|
||||
import { layoutTextRoot } from './figureLayout.js';
|
||||
import PipelineCard from './PipelineCard.vue';
|
||||
import ToolCard from './ToolCard.vue';
|
||||
|
||||
@@ -43,6 +44,36 @@ const onLightboxKeydown = (e) => {
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
};
|
||||
|
||||
// ---------- 图文布局(2026-09-01):单图 50% 宽;连续同比例双图并排 ----------
|
||||
const bodyEl = ref(null);
|
||||
let layoutTimer = null;
|
||||
|
||||
/** 对消息体所有 text 块跑布局;返回仍在等加载的 img 列表(比例未知) */
|
||||
const runFigureLayout = () => {
|
||||
if (!bodyEl.value) return;
|
||||
for (const root of bodyEl.value.querySelectorAll('.md-text')) {
|
||||
layoutTextRoot(root);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleLayout = () => {
|
||||
if (layoutTimer) clearTimeout(layoutTimer);
|
||||
layoutTimer = setTimeout(runFigureLayout, 300);
|
||||
};
|
||||
|
||||
// 图片加载完成后立即重排(capture 委托,与 onImageError 同机制)
|
||||
const onImageLoad = (e) => {
|
||||
if (e.target instanceof HTMLImageElement && e.target.closest('.md-figure')) {
|
||||
scheduleLayout();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(scheduleLayout);
|
||||
onUpdated(scheduleLayout);
|
||||
onUnmounted(() => {
|
||||
if (layoutTimer) clearTimeout(layoutTimer);
|
||||
});
|
||||
|
||||
// text 块 html 在创建/落库时已渲染为消毒 HTML(markdown.js renderMarkdown);
|
||||
// 渲染前再消毒一次(纵深防御,FR-CHAT-004:LLM 输出必须消毒)
|
||||
const renderedText = (html) => sanitizeHtml(html);
|
||||
@@ -94,10 +125,10 @@ const showRetry = computed(() => !!(props.onRetry && props.message.status === 'e
|
||||
<span class="who">{{ T.assistantName }}</span>
|
||||
<span class="when">{{ message.timeLabel }}</span>
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div ref="bodyEl" class="msg-body" @load.capture="onImageLoad">
|
||||
<template v-for="(b, bi) in message.blocks" :key="bi">
|
||||
<!-- text:markdown-it → DOMPurify → v-html(FR-CHAT-004);
|
||||
图片事件经 capture 委托绑定(点击放大 / 加载失败隐藏) -->
|
||||
图片事件经 capture 委托绑定(点击放大 / 加载失败隐藏 / 加载完成重排) -->
|
||||
<div
|
||||
v-if="b.type === 'text'"
|
||||
class="md-text"
|
||||
@@ -434,23 +465,38 @@ const showRetry = computed(() => !!(props.onRetry && props.message.status === 'e
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
|
||||
/* ---------- 图文并茂(2026-08-28) ---------- */
|
||||
.md-figure {
|
||||
/* ---------- 图文并茂(2026-08-28 图注块;2026-09-01 布局:单图 50% 宽 / 连续同比例双图并排) ---------- */
|
||||
/* v-html 内容不带 scope 属性,必须 :deep() 才能命中 */
|
||||
:deep(.md-figure) {
|
||||
margin: 14px 0;
|
||||
cursor: zoom-in;
|
||||
width: 50%; /* 默认半宽(单图);figure-row 内由 flex-basis 接管 */
|
||||
}
|
||||
.md-figure img {
|
||||
:deep(.md-figure img) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.md-figure figcaption {
|
||||
:deep(.md-figure figcaption) {
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* 连续同比例双图并排:gap 14px,两侧 figure 各占 calc(50% - 7px);
|
||||
一张 onerror 隐藏后另一张仍保持半宽,不会撑满 */
|
||||
:deep(.figure-row) {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin: 14px 0;
|
||||
}
|
||||
:deep(.figure-row .md-figure) {
|
||||
flex: 0 0 calc(50% - 7px);
|
||||
margin: 0;
|
||||
}
|
||||
.figure-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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: [] };
|
||||
}
|
||||
Reference in New Issue
Block a user