История: словный diff в том же текстовом блоке + единая палитра

- Текст del/add парой сливается в ОДИН блок (change=mod): словный LCS по токенам, плейсхолдеры U+E000/U+E001, рендер, подстановка цветных span только в текстовых узлах; ссылки/URL/код — fallback на два блока чтобы не рвать разметку
- Единые насыщенные цвета везде: rgba(244,63,94,.45)/rgba(34,197,94,.45) — html тинты, строки кода, слова, текстовый diff, легенда (hex с альфой в QML)
- Тесты: text merge, link fallback, цвета
This commit is contained in:
2026-09-06 20:30:01 +03:00
parent e292f6586e
commit 781e1dfc12
5 changed files with 152 additions and 26 deletions
+2 -2
View File
@@ -574,9 +574,9 @@ QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePa
QChar first = rawLine.at(0);
QString esc = escHtml(rawLine);
if (first == '+') {
html += QStringLiteral("<div style='background:#b7f5c6;color:#0d5c2c'>%1</div>").arg(esc);
html += QStringLiteral("<div style='background:rgba(34,197,94,0.45);color:#0d5c2c'>%1</div>").arg(esc);
} else if (first == '-') {
html += QStringLiteral("<div style='background:#ffd6dd;color:#b3192e'>%1</div>").arg(esc);
html += QStringLiteral("<div style='background:rgba(244,63,94,0.45);color:#b3192e'>%1</div>").arg(esc);
} else if (first == '@') {
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
} else if (first == ' ') {
+113 -16
View File
@@ -208,12 +208,44 @@ QVector<DiffOp> diffMdBlocks(const QStringList &a, const QStringList &b)
return ops;
}
// Цветная обёртка изменённого блока: насыщенные розовый/зелёный, текст читается
// Цветная обёртка изменённого блока: единая насыщенная палитра везде
// (полутона читаются и на светлом, и на тёмном фоне, и в синем пузыре кода)
QString tintDiffHtml(const QString &inner, bool isDel)
{
if (isDel)
return QStringLiteral("<div style=\"background-color:#ffd6dd;border-left:3px solid #e5484d;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
return QStringLiteral("<div style=\"background-color:#b7f5c6;border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
return QStringLiteral("<div style=\"background-color:rgba(244,63,94,0.45);border-left:3px solid #e5484d;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
return QStringLiteral("<div style=\"background-color:rgba(34,197,94,0.45);border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
}
// Токенизация на слова/пробелы для словного diff (границы слов не рвём)
QStringList tokenizeWords(const QString &s)
{
QStringList toks;
QString cur;
bool curSpace = false;
bool first = true;
for (const QChar &c : s) {
const bool sp = c.isSpace();
if (first) { cur += c; curSpace = sp; first = false; continue; }
if (sp == curSpace)
cur += c;
else {
toks.append(cur);
cur = QString(c);
curSpace = sp;
}
}
if (!cur.isEmpty())
toks.append(cur);
return toks;
}
// Блоки, где встроенный словный diff опасен (ссылки/URL/код — плейсхолдеры
// порвут разметку): для них оставляем два отдельных tinted-блока
bool riskyInlineMerge(const QString &block)
{
return block.contains(QLatin1Char('`')) || block.contains(QLatin1String("]("))
|| block.contains(QLatin1String("://"));
}
QString fileHrefFor(const QString &path)
@@ -1015,14 +1047,6 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
blocks.append(block);
};
// Насыщенная полупрозрачная подложка для кода: пузырь CodeBlock всегда тёмно-синий,
// светлый текст на ней читается, в отличие от пастели
auto tintCodeHtml = [&](const QString &inner, bool isDel) -> QString {
const QString bg = isDel ? QStringLiteral("rgba(244,63,94,0.45)")
: QStringLiteral("rgba(34,197,94,0.45)");
return QStringLiteral("<div style=\"background-color:%1;border-radius:6px;padding:6px 8px;margin:4px 0\">%2</div>").arg(bg, inner);
};
auto appendCodeBlock = [&](const QString &lang, const QString &code, const char *change, bool tint) {
QString codeId;
if (m) {
@@ -1033,7 +1057,7 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true)
: plainToRich(code);
if (tint)
highlighted = tintCodeHtml(highlighted, change[0] == 'd');
highlighted = tintDiffHtml(highlighted, change[0] == 'd');
QVariantMap block;
block["type"] = "code";
block["codeText"] = code;
@@ -1107,6 +1131,73 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
return true;
};
// Обычный текст (del+add парой): один блок со словным diff внутри через
// плейсхолдеры — разметка не рвётся, подстановка только в текстовых узлах
auto appendMergedText = [&](const QString &oldT, const QString &newT) -> bool {
if (riskyInlineMerge(oldT) || riskyInlineMerge(newT))
return false;
const QStringList a = tokenizeWords(oldT);
const QStringList b = tokenizeWords(newT);
if (a.isEmpty() || b.isEmpty())
return false;
if ((long long)a.size() * b.size() > 250000)
return false;
const QVector<DiffOp> wops = diffMdBlocks(a, b);
struct Ph { QString token; QString word; bool isDel; };
QVector<Ph> phs;
QString merged;
int phId = 0;
bool hasChange = false;
for (const DiffOp &w : wops) {
if (w.kind == '=') {
merged += w.text;
} else {
hasChange = true;
const QString ph = QString(QChar(0xE000)) + QString::number(phId++)
+ QString(QChar(0xE001));
merged += ph;
phs.append({ph, w.text, w.kind == '-'});
}
}
if (!hasChange)
return false;
QString html = renderKeep(merged);
if (html.trimmed().isEmpty())
return false;
// подстановка плейсхолдеров: в текстовых узлах — цветной span,
// внутри тегов (ссылки/атрибуты) — просто слово, чтобы не ломать разметку
for (const Ph &ph : phs) {
const QString styled = QStringLiteral("<span style=\"background-color:%1;border-radius:3px;padding:0 2px;\">%2</span>")
.arg(ph.isDel ? QStringLiteral("rgba(244,63,94,0.45)")
: QStringLiteral("rgba(34,197,94,0.45)"),
esc(ph.word));
const QString plain = esc(ph.word);
QString out;
out.reserve(html.size() + styled.size());
int pos = 0;
bool found = false;
while (true) {
const int f = html.indexOf(ph.token, pos);
if (f < 0) {
out += html.mid(pos);
break;
}
found = true;
const int lt = html.lastIndexOf(QLatin1Char('<'), f);
const int gt = (lt >= 0) ? html.indexOf(QLatin1Char('>'), lt) : -1;
const bool inTag = (lt >= 0 && (gt < 0 || gt > f));
out += html.mid(pos, f - pos);
out += inTag ? plain : styled;
pos = f + ph.token.length();
}
if (!found)
return false;
html = out;
}
appendHtmlBlock(html, "mod");
return true;
};
const QVector<DiffOp> ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd));
for (int k = 0; k < ops.size(); ++k) {
const DiffOp &op = ops[k];
@@ -1115,10 +1206,16 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add");
QString lang, code;
const bool isCode = isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code);
if (isCode && isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') {
QString nlang, ncode;
if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode)
&& appendMergedCode(lang, code, nlang, ncode)) {
if (isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') {
if (isCode) {
QString nlang, ncode;
if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode)
&& appendMergedCode(lang, code, nlang, ncode)) {
++k;
continue;
}
} else if (!isFenceBlock(ops[k + 1].text)
&& appendMergedText(op.text, ops[k + 1].text)) {
++k;
continue;
}