История: словный 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:
@@ -366,8 +366,8 @@ Dialog {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.margins: 6
|
Layout.margins: 6
|
||||||
spacing: 12
|
spacing: 12
|
||||||
Row { spacing: 4; Rectangle { width:12; height:12; color:"#b7f5c6"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } }
|
Row { spacing: 4; Rectangle { width:12; height:12; color:"#7322C55E"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||||
Row { spacing: 4; Rectangle { width:12; height:12; color:"#ffd6dd"; border.color:"#e5484d"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } }
|
Row { spacing: 4; Rectangle { width:12; height:12; color:"#73F43F5E"; border.color:"#e5484d"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||||
Row { visible: tabBar.currentIndex === 0; spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } }
|
Row { visible: tabBar.currentIndex === 0; spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||||
Label {
|
Label {
|
||||||
text: tabBar.currentIndex === 0
|
text: tabBar.currentIndex === 0
|
||||||
|
|||||||
@@ -574,9 +574,9 @@ QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePa
|
|||||||
QChar first = rawLine.at(0);
|
QChar first = rawLine.at(0);
|
||||||
QString esc = escHtml(rawLine);
|
QString esc = escHtml(rawLine);
|
||||||
if (first == '+') {
|
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 == '-') {
|
} 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 == '@') {
|
} else if (first == '@') {
|
||||||
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
|
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
|
||||||
} else if (first == ' ') {
|
} else if (first == ' ') {
|
||||||
|
|||||||
+110
-13
@@ -208,12 +208,44 @@ QVector<DiffOp> diffMdBlocks(const QStringList &a, const QStringList &b)
|
|||||||
return ops;
|
return ops;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Цветная обёртка изменённого блока: насыщенные розовый/зелёный, текст читается
|
// Цветная обёртка изменённого блока: единая насыщенная палитра везде
|
||||||
|
// (полутона читаются и на светлом, и на тёмном фоне, и в синем пузыре кода)
|
||||||
QString tintDiffHtml(const QString &inner, bool isDel)
|
QString tintDiffHtml(const QString &inner, bool isDel)
|
||||||
{
|
{
|
||||||
if (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: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:#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(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)
|
QString fileHrefFor(const QString &path)
|
||||||
@@ -1015,14 +1047,6 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
blocks.append(block);
|
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) {
|
auto appendCodeBlock = [&](const QString &lang, const QString &code, const char *change, bool tint) {
|
||||||
QString codeId;
|
QString codeId;
|
||||||
if (m) {
|
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)
|
QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true)
|
||||||
: plainToRich(code);
|
: plainToRich(code);
|
||||||
if (tint)
|
if (tint)
|
||||||
highlighted = tintCodeHtml(highlighted, change[0] == 'd');
|
highlighted = tintDiffHtml(highlighted, change[0] == 'd');
|
||||||
QVariantMap block;
|
QVariantMap block;
|
||||||
block["type"] = "code";
|
block["type"] = "code";
|
||||||
block["codeText"] = code;
|
block["codeText"] = code;
|
||||||
@@ -1107,6 +1131,73 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
return true;
|
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));
|
const QVector<DiffOp> ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd));
|
||||||
for (int k = 0; k < ops.size(); ++k) {
|
for (int k = 0; k < ops.size(); ++k) {
|
||||||
const DiffOp &op = ops[k];
|
const DiffOp &op = ops[k];
|
||||||
@@ -1115,13 +1206,19 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
|||||||
const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add");
|
const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add");
|
||||||
QString lang, code;
|
QString lang, code;
|
||||||
const bool isCode = isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code);
|
const bool isCode = isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code);
|
||||||
if (isCode && isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') {
|
if (isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') {
|
||||||
|
if (isCode) {
|
||||||
QString nlang, ncode;
|
QString nlang, ncode;
|
||||||
if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode)
|
if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode)
|
||||||
&& appendMergedCode(lang, code, nlang, ncode)) {
|
&& appendMergedCode(lang, code, nlang, ncode)) {
|
||||||
++k;
|
++k;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
} else if (!isFenceBlock(ops[k + 1].text)
|
||||||
|
&& appendMergedText(op.text, ops[k + 1].text)) {
|
||||||
|
++k;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isCode) {
|
if (isCode) {
|
||||||
appendCodeBlock(lang, code, change, changed);
|
appendCodeBlock(lang, code, change, changed);
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ int main(int argc, char *argv[])
|
|||||||
if(!diff.contains("+") || !diff.contains("-")) return fail("diff should have + -", diff);
|
if(!diff.contains("+") || !diff.contains("-")) return fail("diff should have + -", diff);
|
||||||
if(!diff.contains("Версия 1") || !diff.contains("Версия 2")) return fail("diff contains versions", diff);
|
if(!diff.contains("Версия 1") || !diff.contains("Версия 2")) return fail("diff contains versions", diff);
|
||||||
QString diffHtml = git.fileDiffHtml(vault, file, hash1);
|
QString diffHtml = git.fileDiffHtml(vault, file, hash1);
|
||||||
if(!diffHtml.contains("background:#b7f5c6") || !diffHtml.contains("background:#ffd6dd")) return fail("diffHtml colors", diffHtml.left(500));
|
if(!diffHtml.contains("34,197,94") || !diffHtml.contains("244,63,94")) return fail("diffHtml colors", diffHtml.left(500));
|
||||||
|
|
||||||
// forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти
|
// forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти
|
||||||
QString other = vault+"/other.md";
|
QString other = vault+"/other.md";
|
||||||
|
|||||||
+34
-5
@@ -149,19 +149,20 @@ int main(int argc, char *argv[])
|
|||||||
const QString newMd = "# Заголовок\n\nНовая строка\n\n```cpp\nint x = 2;\n```\n";
|
const QString newMd = "# Заголовок\n\nНовая строка\n\n```cpp\nint x = 2;\n```\n";
|
||||||
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||||
if (d.isEmpty()) return fail("diff пуст");
|
if (d.isEmpty()) return fail("diff пуст");
|
||||||
// порядок как у стандартного diff: сначала все del изменённого куска, потом все add
|
// порядок как у стандартного diff: сначала все del изменённого куска, потом все add,
|
||||||
|
// поэтому пары del/add здесь не соседствуют и merge не срабатывает — отдельные блоки
|
||||||
auto changeOf = [&](int i) { return d[i].toMap().value("change").toString(); };
|
auto changeOf = [&](int i) { return d[i].toMap().value("change").toString(); };
|
||||||
if (d.size() != 5) return fail("diff размер", QString::number(d.size()));
|
if (d.size() != 5) return fail("diff размер", QString::number(d.size()));
|
||||||
if (changeOf(0) != "same") return fail("diff[0] должен быть same", changeOf(0));
|
if (changeOf(0) != "same") return fail("diff[0] должен быть same", changeOf(0));
|
||||||
if (d[0].toMap().value("type").toString() != "html") return fail("diff[0] тип");
|
if (d[0].toMap().value("type").toString() != "html") return fail("diff[0] тип");
|
||||||
if (changeOf(1) != "del") return fail("diff[1] должен быть del", changeOf(1));
|
if (changeOf(1) != "del") return fail("diff[1] должен быть del", changeOf(1));
|
||||||
if (!d[1].toMap().value("html").toString().contains("ffd6dd")) return fail("diff[1] без розовой подсветки");
|
if (!d[1].toMap().value("html").toString().contains("244,63,94")) return fail("diff[1] без розовой подсветки");
|
||||||
if (changeOf(2) != "del" || d[2].toMap().value("type").toString() != "code")
|
if (changeOf(2) != "del" || d[2].toMap().value("type").toString() != "code")
|
||||||
return fail("diff[2] код del", changeOf(2));
|
return fail("diff[2] код del", changeOf(2));
|
||||||
if (!d[2].toMap().value("highlightedText").toString().contains("244,63,94"))
|
if (!d[2].toMap().value("highlightedText").toString().contains("244,63,94"))
|
||||||
return fail("diff[2] код без розовой подсветки");
|
return fail("diff[2] код без розовой подсветки");
|
||||||
if (changeOf(3) != "add") return fail("diff[3] должен быть add", changeOf(3));
|
if (changeOf(3) != "add") return fail("diff[3] должен быть add", changeOf(3));
|
||||||
if (!d[3].toMap().value("html").toString().contains("b7f5c6")) return fail("diff[3] без зелёной подсветки");
|
if (!d[3].toMap().value("html").toString().contains("34,197,94")) return fail("diff[3] без зелёной подсветки");
|
||||||
if (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code")
|
if (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code")
|
||||||
return fail("diff[4] код add", changeOf(4));
|
return fail("diff[4] код add", changeOf(4));
|
||||||
// кодовые блоки сохранили id для копирования
|
// кодовые блоки сохранили id для копирования
|
||||||
@@ -177,12 +178,40 @@ int main(int argc, char *argv[])
|
|||||||
for (const auto &v : d) {
|
for (const auto &v : d) {
|
||||||
if (v.toMap().value("change").toString() != "same")
|
if (v.toMap().value("change").toString() != "same")
|
||||||
return fail("identical должен быть same");
|
return fail("identical должен быть same");
|
||||||
if (v.toMap().value("html").toString().contains("ffd6dd")
|
if (v.toMap().value("html").toString().contains("244,63,94")
|
||||||
|| v.toMap().value("html").toString().contains("b7f5c6"))
|
|| v.toMap().value("html").toString().contains("34,197,94"))
|
||||||
return fail("identical с подсветкой");
|
return fail("identical с подсветкой");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseBlocksWithDiff: правка в обычном тексте — ОДИН блок со словным diff внутри
|
||||||
|
{
|
||||||
|
const QString oldMd = "# T\n\nOld words here\n";
|
||||||
|
const QString newMd = "# T\n\nNew words here\n";
|
||||||
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||||
|
if (d.size() != 2) return fail("text merge размер", QString::number(d.size()));
|
||||||
|
if (d[0].toMap().value("change").toString() != "same") return fail("text merge[0] same");
|
||||||
|
if (d[1].toMap().value("type").toString() != "html"
|
||||||
|
|| d[1].toMap().value("change").toString() != "mod")
|
||||||
|
return fail("text merge[1] должен быть html/mod", d[1].toMap().value("change").toString());
|
||||||
|
const QString html = d[1].toMap().value("html").toString();
|
||||||
|
if (!html.contains("244,63,94")) return fail("text merge без розового");
|
||||||
|
if (!html.contains("34,197,94")) return fail("text merge без зелёного");
|
||||||
|
if (!html.contains("Old") || !html.contains("New")) return fail("text merge потерял слова", html.left(300));
|
||||||
|
if (!html.contains("words here")) return fail("text merge потерял общее", html.left(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBlocksWithDiff: ссылка поменялась — fallback на два отдельных блока (разметку не рвём)
|
||||||
|
{
|
||||||
|
const QString oldMd = "# T\n\n[a](http://old.com/x)\n";
|
||||||
|
const QString newMd = "# T\n\n[a](http://new.com/x)\n";
|
||||||
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||||
|
if (d.size() != 3) return fail("link fallback размер", QString::number(d.size()));
|
||||||
|
if (d[1].toMap().value("change").toString() != "del"
|
||||||
|
|| d[2].toMap().value("change").toString() != "add")
|
||||||
|
return fail("link fallback del/add");
|
||||||
|
}
|
||||||
|
|
||||||
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
||||||
{
|
{
|
||||||
const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";
|
const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user