Compare commits
4 Commits
6e49c4b5f8
...
e292f6586e
| Author | SHA1 | Date | |
|---|---|---|---|
| e292f6586e | |||
| beb1eb2956 | |||
| 3d04ac0095 | |||
| 9c8215b707 |
@@ -29,11 +29,39 @@ Dialog {
|
|||||||
property int selectedIndex: -1
|
property int selectedIndex: -1
|
||||||
property string selectedHash: ""
|
property string selectedHash: ""
|
||||||
property string diffHtml: ""
|
property string diffHtml: ""
|
||||||
|
property string versionContent: ""
|
||||||
|
property string currentContent: ""
|
||||||
property bool busy: false
|
property bool busy: false
|
||||||
|
|
||||||
|
function fileDirOf(p) {
|
||||||
|
const i = p.lastIndexOf("/")
|
||||||
|
return i >= 0 ? p.substring(0, i) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылки внутри просмотра исторической версии: только чтение, без правок
|
||||||
|
function handleHistoryLink(href) {
|
||||||
|
if (href.startsWith("flora://copycode/")) {
|
||||||
|
copyFeedback.text = qsTr("Скопировано!")
|
||||||
|
copyFeedback.visible = true
|
||||||
|
copyTimer.restart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (href.startsWith("flora://task/")) {
|
||||||
|
return // историческая версия — только чтение, галочки не переключаем
|
||||||
|
} else if (href.startsWith("flora://open/")) {
|
||||||
|
documentManager.openDocument(decodeURIComponent(href.substring(13)))
|
||||||
|
} else if (href.startsWith("flora://wiki/")) {
|
||||||
|
return // несуществующие заметки из истории не создаём
|
||||||
|
} else {
|
||||||
|
Qt.openUrlExternally(href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
if (vaultPath.length===0 || filePath.length===0) {
|
if (vaultPath.length===0 || filePath.length===0) {
|
||||||
historyModel = []
|
historyModel = []
|
||||||
|
versionContent = ""
|
||||||
|
currentContent = ""
|
||||||
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>"
|
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -49,6 +77,8 @@ Dialog {
|
|||||||
} else {
|
} else {
|
||||||
selectedIndex = -1
|
selectedIndex = -1
|
||||||
selectedHash = ""
|
selectedHash = ""
|
||||||
|
versionContent = ""
|
||||||
|
currentContent = ""
|
||||||
const hasFile = filePath.length>0
|
const hasFile = filePath.length>0
|
||||||
if (hasFile) {
|
if (hasFile) {
|
||||||
const st = gitManager.fileGitStatus(vaultPath, filePath)
|
const st = gitManager.fileGitStatus(vaultPath, filePath)
|
||||||
@@ -67,14 +97,18 @@ Dialog {
|
|||||||
function updateDiff() {
|
function updateDiff() {
|
||||||
if (selectedHash.length===0) {
|
if (selectedHash.length===0) {
|
||||||
diffHtml = ""
|
diffHtml = ""
|
||||||
|
versionContent = ""
|
||||||
|
currentContent = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
|
||||||
|
currentContent = gitManager.workdirContent(filePath)
|
||||||
const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
|
const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
|
||||||
if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>"
|
if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>"
|
||||||
else diffHtml = html
|
else diffHtml = html
|
||||||
// также обновляем превью содержимого версии
|
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
|
||||||
const content = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
|
// с подсветкой отличий от текущего файла прямо там
|
||||||
previewArea.text = content
|
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpened: reload()
|
onOpened: reload()
|
||||||
@@ -268,7 +302,7 @@ Dialog {
|
|||||||
id: tabBar
|
id: tabBar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
TabButton { text: qsTr("Изменения") }
|
TabButton { text: qsTr("Изменения") }
|
||||||
TabButton { text: qsTr("Содержимое версии") }
|
TabButton { text: qsTr("Просмотр версии") }
|
||||||
}
|
}
|
||||||
|
|
||||||
StackLayout {
|
StackLayout {
|
||||||
@@ -300,39 +334,45 @@ Dialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview вкладка
|
// Просмотр версии — как кнопка «Просмотр» в редакторе
|
||||||
Rectangle {
|
Rectangle {
|
||||||
color: Theme.editorBg
|
color: Theme.editorBg
|
||||||
border.color: Theme.divider
|
border.color: Theme.divider
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
id: versionScroll
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 6
|
anchors.margins: 6
|
||||||
clip: true
|
clip: true
|
||||||
ScrollBar.vertical: ScrollBar {}
|
ScrollBar.vertical: ScrollBar {}
|
||||||
TextArea {
|
Column {
|
||||||
id: previewArea
|
width: versionScroll.availableWidth
|
||||||
readOnly: true
|
MarkdownView {
|
||||||
wrapMode: Text.Wrap
|
id: versionPreview
|
||||||
color: Theme.text
|
width: parent.width
|
||||||
selectionColor: Theme.selectionBg
|
markdown: root.versionContent
|
||||||
background: Rectangle { color: Theme.editorBg }
|
compareMarkdown: root.currentContent
|
||||||
font.family: "Menlo, monospace"
|
vaultRoot: root.vaultPath
|
||||||
font.pixelSize: 12
|
fileDir: root.fileDirOf(root.filePath)
|
||||||
|
emptyText: qsTr("Нет версии для просмотра")
|
||||||
|
onLinkActivated: (href) => root.handleHistoryLink(href)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Легенда
|
// Легенда — цвета отличий от текущего файла (обе вкладки)
|
||||||
Row {
|
Row {
|
||||||
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:"#e6ffec"; 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:"#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:"#ffebe9"; border.color:"#cf222e"; 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:"#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: qsTr("Сравнение выбранной версии с текущим файлом")
|
text: tabBar.currentIndex === 0
|
||||||
|
? qsTr("Сравнение выбранной версии с текущим файлом")
|
||||||
|
: qsTr("Версия с подсветкой отличий от текущего файла")
|
||||||
color: Theme.textFaint
|
color: Theme.textFaint
|
||||||
font.pixelSize: 10
|
font.pixelSize: 10
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -436,4 +476,27 @@ Dialog {
|
|||||||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||||||
Label { id: errorLabel; color: Theme.errorText; wrapMode: Text.Wrap; width: 360 }
|
Label { id: errorLabel; color: Theme.errorText; wrapMode: Text.Wrap; width: 360 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Тост "Скопировано" для кнопки копирования в блоках кода
|
||||||
|
Rectangle {
|
||||||
|
id: copyToast
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.topMargin: 8
|
||||||
|
visible: copyFeedback.visible
|
||||||
|
color: "#333"
|
||||||
|
radius: 6
|
||||||
|
width: copyFeedback.implicitWidth + 16
|
||||||
|
height: copyFeedback.implicitHeight + 10
|
||||||
|
z: 10
|
||||||
|
Label {
|
||||||
|
id: copyFeedback
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: false
|
||||||
|
text: qsTr("Скопировано!")
|
||||||
|
color: "white"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timer { id: copyTimer; interval: 1500; onTriggered: copyFeedback.visible = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,37 @@ Canvas {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Дебаунс перестройки на случай если nodes и edges меняются парой
|
||||||
|
Timer {
|
||||||
|
id: layoutDebounce
|
||||||
|
interval: 120
|
||||||
|
repeat: false
|
||||||
|
onTriggered: g.relayout()
|
||||||
|
}
|
||||||
|
function requestRelayout() { layoutDebounce.restart() }
|
||||||
|
|
||||||
|
// При ресайзе только подрезаем координаты и перерисовываем — без force-симуляции
|
||||||
|
function clampPositions() {
|
||||||
|
let changed = false
|
||||||
|
for (const k in pos) {
|
||||||
|
const p = pos[k]
|
||||||
|
const nx = Math.max(30, Math.min(width - 30, p.x))
|
||||||
|
const ny = Math.max(30, Math.min(height - 30, p.y))
|
||||||
|
if (nx !== p.x || ny !== p.y) {
|
||||||
|
pos[k] = { x: nx, y: ny }
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// присваивание нужно только если что-то вылезло за границы,
|
||||||
|
// requestPaint вызываем в любом случае из обработчиков размера
|
||||||
|
if (changed) pos = Object.assign({}, pos)
|
||||||
|
}
|
||||||
|
|
||||||
function relayout() {
|
function relayout() {
|
||||||
const n = nodes.length
|
const n = nodes.length
|
||||||
if (n === 0) { pos = {}; requestPaint(); return }
|
if (n === 0) { pos = {}; requestPaint(); return }
|
||||||
|
// Размер ещё не известен (создание компонента) — откладываем
|
||||||
|
if (width < 50 || height < 50) { layoutDebounce.restart(); return }
|
||||||
if (n > 600) {
|
if (n > 600) {
|
||||||
// Слишком много узлов — показываем упрощённо без симуляции чтобы не заморозить UI
|
// Слишком много узлов — показываем упрощённо без симуляции чтобы не заморозить UI
|
||||||
const cx = width / 2, cy = height / 2
|
const cx = width / 2, cy = height / 2
|
||||||
@@ -35,16 +63,17 @@ Canvas {
|
|||||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||||
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
||||||
}
|
}
|
||||||
pos = newPos; zoom = 1.0; panX = 0; panY = 0; requestPaint(); return
|
pos = newPos; requestPaint(); return
|
||||||
}
|
}
|
||||||
const cx = width / 2, cy = height / 2
|
const cx = width / 2, cy = height / 2
|
||||||
const rad = Math.min(width, height) / 2 - 36
|
const rad = Math.min(width, height) / 2 - 36
|
||||||
let newPos = {}
|
let newPos = {}
|
||||||
for (let i = 0; i < n; ++i) {
|
for (let i = 0; i < n; ++i) {
|
||||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||||
// небольшой джиттер чтобы не накладывались ровно
|
// детерминированный сдвиг вместо Math.random — раскладка не прыгает при каждом ребилде
|
||||||
const jitter = (Math.random() - 0.5) * 8
|
const jitterX = ((i * 37) % 9) - 4
|
||||||
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a) + jitter, y: cy + rad * Math.sin(a) + jitter }
|
const jitterY = ((i * 53) % 9) - 4
|
||||||
|
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a) + jitterX, y: cy + rad * Math.sin(a) + jitterY }
|
||||||
}
|
}
|
||||||
const iterCount = n > 200 ? 25 : (n > 100 ? 40 : 80)
|
const iterCount = n > 200 ? 25 : (n > 100 ? 40 : 80)
|
||||||
// простая force-симуляция для разведения узлов (репульсия + притяжение по рёбрам)
|
// простая force-симуляция для разведения узлов (репульсия + притяжение по рёбрам)
|
||||||
@@ -89,13 +118,16 @@ Canvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pos = newPos
|
pos = newPos
|
||||||
zoom = 1.0; panX = 0; panY = 0
|
|
||||||
requestPaint()
|
requestPaint()
|
||||||
}
|
}
|
||||||
|
|
||||||
onNodesChanged: relayout()
|
function resetView() { zoom = 1.0; panX = 0; panY = 0; requestPaint() }
|
||||||
onWidthChanged: relayout()
|
|
||||||
onHeightChanged: relayout()
|
onNodesChanged: requestRelayout()
|
||||||
|
onEdgesChanged: requestRelayout()
|
||||||
|
// Ресайз окна: только перерисовка, БЕЗ перестройки графа и БЕЗ сброса зума/панорамы
|
||||||
|
onWidthChanged: { clampPositions(); requestPaint() }
|
||||||
|
onHeightChanged: { clampPositions(); requestPaint() }
|
||||||
|
|
||||||
// пустое состояние
|
// пустое состояние
|
||||||
Text {
|
Text {
|
||||||
@@ -213,6 +245,6 @@ Canvas {
|
|||||||
g.dragKey = ""
|
g.dragKey = ""
|
||||||
g.requestPaint()
|
g.requestPaint()
|
||||||
}
|
}
|
||||||
onDoubleClicked: { g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout() }
|
onDoubleClicked: { g.resetView(); g.relayout() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Item {
|
|||||||
property string markdown: ""
|
property string markdown: ""
|
||||||
property string vaultRoot: ""
|
property string vaultRoot: ""
|
||||||
property string fileDir: ""
|
property string fileDir: ""
|
||||||
|
property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования")
|
||||||
|
// Если задан — markdown считается старой версией и подсвечиваются отличия от него
|
||||||
|
// (блоки del — только в markdown, add — только в compareMarkdown)
|
||||||
|
property string compareMarkdown: ""
|
||||||
implicitHeight: column.implicitHeight + 24
|
implicitHeight: column.implicitHeight + 24
|
||||||
|
|
||||||
signal linkActivated(string href)
|
signal linkActivated(string href)
|
||||||
@@ -16,7 +20,9 @@ Item {
|
|||||||
function update() {
|
function update() {
|
||||||
// Триггерим пересборку блоков
|
// Триггерим пересборку блоков
|
||||||
blocksModel.clear()
|
blocksModel.clear()
|
||||||
const blocks = mdParser.parseBlocks(markdown, vaultRoot, fileDir)
|
const blocks = compareMarkdown.length > 0
|
||||||
|
? mdParser.parseBlocksWithDiff(markdown, compareMarkdown, vaultRoot, fileDir)
|
||||||
|
: mdParser.parseBlocks(markdown, vaultRoot, fileDir)
|
||||||
for (let i = 0; i < blocks.length; i++) {
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
blocksModel.append(blocks[i])
|
blocksModel.append(blocks[i])
|
||||||
}
|
}
|
||||||
@@ -25,6 +31,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMarkdownChanged: update()
|
onMarkdownChanged: update()
|
||||||
|
onCompareMarkdownChanged: update()
|
||||||
onVaultRootChanged: update()
|
onVaultRootChanged: update()
|
||||||
onFileDirChanged: update()
|
onFileDirChanged: update()
|
||||||
|
|
||||||
@@ -86,7 +93,7 @@ Item {
|
|||||||
|
|
||||||
Label {
|
Label {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: markdown.trim().length === 0 ? qsTr("Пустой файл — начните писать в режиме редактирования") : ""
|
text: markdown.trim().length === 0 ? emptyText : ""
|
||||||
color: Theme.textFaint
|
color: Theme.textFaint
|
||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
font.italic: true
|
font.italic: true
|
||||||
|
|||||||
+10
-2
@@ -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:#e6ffec;color:#1a7f37'>%1</div>").arg(esc);
|
html += QStringLiteral("<div style='background:#b7f5c6;color:#0d5c2c'>%1</div>").arg(esc);
|
||||||
} else if (first == '-') {
|
} else if (first == '-') {
|
||||||
html += QStringLiteral("<div style='background:#ffebe9;color:#cf222e'>%1</div>").arg(esc);
|
html += QStringLiteral("<div style='background:#ffd6dd;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 == ' ') {
|
||||||
@@ -627,6 +627,14 @@ QString GitManager::restoreFile(const QString &vaultPath, const QString &filePat
|
|||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString GitManager::workdirContent(const QString &filePath) const
|
||||||
|
{
|
||||||
|
QFile f(filePath);
|
||||||
|
if (!f.open(QIODevice::ReadOnly) || f.size() > 2 * 1024 * 1024)
|
||||||
|
return QString();
|
||||||
|
return QString::fromUtf8(f.readAll());
|
||||||
|
}
|
||||||
|
|
||||||
QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const
|
QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const
|
||||||
{
|
{
|
||||||
const QVariantList hist = fileHistory(vaultPath, filePath);
|
const QVariantList hist = fileHistory(vaultPath, filePath);
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ public:
|
|||||||
// Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал.
|
// Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал.
|
||||||
Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
||||||
|
|
||||||
|
// Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ.
|
||||||
|
Q_INVOKABLE QString workdirContent(const QString &filePath) const;
|
||||||
|
|
||||||
// Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами.
|
// Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами.
|
||||||
Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
||||||
// Текстовый diff (unified)
|
// Текстовый diff (unified)
|
||||||
|
|||||||
+297
-5
@@ -92,6 +92,130 @@ QString safeHtmlOrEscaped(const QString &html)
|
|||||||
return isSafeHtmlTag(html) ? html : esc(html);
|
return isSafeHtmlTag(html) ? html : esc(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Плоский текст → RichText с явными переносами (иначе всё склеится в одну строку)
|
||||||
|
QString plainToRich(const QString &s)
|
||||||
|
{
|
||||||
|
QString e = s.toHtmlEscaped();
|
||||||
|
e.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizeLang(QString l); // определена ниже
|
||||||
|
|
||||||
|
// Разбивка markdown на блоки: абзацы/заголовки/списки/таблицы — по пустым строкам,
|
||||||
|
// fenced-блоки (```/~~~) — целиком, не разрывая.
|
||||||
|
QStringList splitMdBlocks(const QString &md)
|
||||||
|
{
|
||||||
|
QStringList blocks;
|
||||||
|
QStringList cur;
|
||||||
|
bool inFence = false;
|
||||||
|
auto flush = [&]() {
|
||||||
|
while (!cur.isEmpty() && cur.first().trimmed().isEmpty())
|
||||||
|
cur.removeFirst();
|
||||||
|
while (!cur.isEmpty() && cur.last().trimmed().isEmpty())
|
||||||
|
cur.removeLast();
|
||||||
|
if (!cur.isEmpty())
|
||||||
|
blocks.append(cur.join(QLatin1Char('\n')));
|
||||||
|
cur.clear();
|
||||||
|
};
|
||||||
|
const QStringList lines = md.split(QLatin1Char('\n'));
|
||||||
|
for (const QString &ln : lines) {
|
||||||
|
const QString t = ln.trimmed();
|
||||||
|
const bool fence = t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"));
|
||||||
|
if (fence && !inFence) { flush(); cur.append(ln); inFence = true; continue; }
|
||||||
|
if (fence && inFence) { cur.append(ln); inFence = false; flush(); continue; }
|
||||||
|
if (inFence) { cur.append(ln); continue; }
|
||||||
|
if (t.isEmpty()) { flush(); continue; }
|
||||||
|
cur.append(ln);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isFenceBlock(const QString &block)
|
||||||
|
{
|
||||||
|
const QString t = block.trimmed();
|
||||||
|
return t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбор fenced-блока на язык и код. Возвращает false если это не fence.
|
||||||
|
bool parseFenceBlock(const QString &block, QString *langOut, QString *codeOut)
|
||||||
|
{
|
||||||
|
const QStringList lines = block.split(QLatin1Char('\n'));
|
||||||
|
if (lines.size() < 2)
|
||||||
|
return false;
|
||||||
|
const QString first = lines.first().trimmed();
|
||||||
|
QString fence;
|
||||||
|
if (first.startsWith(QLatin1String("```")))
|
||||||
|
fence = QStringLiteral("```");
|
||||||
|
else if (first.startsWith(QLatin1String("~~~")))
|
||||||
|
fence = QStringLiteral("~~~");
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
if (langOut)
|
||||||
|
*langOut = normalizeLang(first.mid(fence.length()).trimmed());
|
||||||
|
int end = lines.size();
|
||||||
|
if (lines.last().trimmed().startsWith(fence))
|
||||||
|
end = lines.size() - 1;
|
||||||
|
if (codeOut)
|
||||||
|
*codeOut = lines.mid(1, end - 1).join(QLatin1Char('\n'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiffOp { char kind; /* '=' same, '-' del (только в old), '+' add (только в new) */ QString text; };
|
||||||
|
|
||||||
|
// Построчный (поблочный) LCS-diff. Для огромных документов — дешёвый префикс/суффикс.
|
||||||
|
QVector<DiffOp> diffMdBlocks(const QStringList &a, const QStringList &b)
|
||||||
|
{
|
||||||
|
const int n = a.size(), m = b.size();
|
||||||
|
QVector<DiffOp> ops;
|
||||||
|
if (n == 0 && m == 0)
|
||||||
|
return ops;
|
||||||
|
auto emitFallback = [&]() {
|
||||||
|
int pre = 0;
|
||||||
|
while (pre < n && pre < m && a[pre] == b[pre])
|
||||||
|
++pre;
|
||||||
|
int suf = 0;
|
||||||
|
while (suf < n - pre && suf < m - pre && a[n - 1 - suf] == b[m - 1 - suf])
|
||||||
|
++suf;
|
||||||
|
for (int i = 0; i < pre; ++i)
|
||||||
|
ops.append({'=', a[i]});
|
||||||
|
for (int i = pre; i < n - suf; ++i)
|
||||||
|
ops.append({'-', a[i]});
|
||||||
|
for (int j = pre; j < m - suf; ++j)
|
||||||
|
ops.append({'+', b[j]});
|
||||||
|
for (int i = n - suf; i < n; ++i)
|
||||||
|
ops.append({'=', a[i]});
|
||||||
|
};
|
||||||
|
if ((long long)(n + 1) * (m + 1) > 250000) {
|
||||||
|
emitFallback();
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
QVector<int> dp((n + 1) * (m + 1), 0);
|
||||||
|
auto IDX = [&](int i, int j) { return i * (m + 1) + j; };
|
||||||
|
for (int i = n - 1; i >= 0; --i)
|
||||||
|
for (int j = m - 1; j >= 0; --j)
|
||||||
|
dp[IDX(i, j)] = (a[i] == b[j]) ? dp[IDX(i + 1, j + 1)] + 1
|
||||||
|
: qMax(dp[IDX(i + 1, j)], dp[IDX(i, j + 1)]);
|
||||||
|
int i = 0, j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] == b[j]) { ops.append({'=', a[i]}); ++i; ++j; }
|
||||||
|
else if (dp[IDX(i + 1, j)] >= dp[IDX(i, j + 1)]) { ops.append({'-', a[i]}); ++i; }
|
||||||
|
else { ops.append({'+', b[j]}); ++j; }
|
||||||
|
}
|
||||||
|
while (i < n) { ops.append({'-', a[i]}); ++i; }
|
||||||
|
while (j < m) { ops.append({'+', b[j]}); ++j; }
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
QString fileHrefFor(const QString &path)
|
QString fileHrefFor(const QString &path)
|
||||||
{
|
{
|
||||||
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
|
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
|
||||||
@@ -200,8 +324,12 @@ public:
|
|||||||
{
|
{
|
||||||
m_segments.clear();
|
m_segments.clear();
|
||||||
Definition def = m_repo->definitionForName(lang);
|
Definition def = m_repo->definitionForName(lang);
|
||||||
if (!def.isValid())
|
if (!def.isValid()) {
|
||||||
return esc(code);
|
// RichText схлопывает \n в пробел — отдаём явные переносы
|
||||||
|
QString e = esc(code);
|
||||||
|
e.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
setDefinition(def);
|
setDefinition(def);
|
||||||
// Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая)
|
// Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая)
|
||||||
Theme chosen = m_repo->defaultTheme();
|
Theme chosen = m_repo->defaultTheme();
|
||||||
@@ -233,7 +361,8 @@ public:
|
|||||||
state = highlightLine(QStringView(line), state);
|
state = highlightLine(QStringView(line), state);
|
||||||
outLines.append(buildLine(line));
|
outLines.append(buildLine(line));
|
||||||
}
|
}
|
||||||
return outLines.join(QLatin1Char('\n'));
|
// Явные <br/>: RichText иначе склеит строки в одну длинную
|
||||||
|
return outLines.join(QStringLiteral("<br/>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -793,7 +922,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
if (m && m->m_repo) {
|
if (m && m->m_repo) {
|
||||||
highlighted = hl.highlight(code, lang, true);
|
highlighted = hl.highlight(code, lang, true);
|
||||||
} else {
|
} else {
|
||||||
highlighted = code.toHtmlEscaped();
|
highlighted = plainToRich(code);
|
||||||
}
|
}
|
||||||
QVariantMap block;
|
QVariantMap block;
|
||||||
block["type"] = "code";
|
block["type"] = "code";
|
||||||
@@ -848,7 +977,170 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
QString MdParser::highlightedCode(const QString &code, const QString &language) const
|
QString MdParser::highlightedCode(const QString &code, const QString &language) const
|
||||||
{
|
{
|
||||||
QString lang = language.trimmed();
|
QString lang = language.trimmed();
|
||||||
if (!m || !m->m_repo) return code.toHtmlEscaped();
|
if (!m || !m->m_repo) return plainToRich(code);
|
||||||
CodeHighlighter hl(m->m_repo);
|
CodeHighlighter hl(m->m_repo);
|
||||||
return hl.highlight(code, lang, true);
|
return hl.highlight(code, lang, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd,
|
||||||
|
const QString &vaultRoot, const QString &fileDir)
|
||||||
|
{
|
||||||
|
QVariantList blocks;
|
||||||
|
if (m) {
|
||||||
|
m->codeBlocks.clear();
|
||||||
|
m->codeCounter = 0;
|
||||||
|
}
|
||||||
|
if (oldMd.isEmpty() && newMd.isEmpty())
|
||||||
|
return blocks;
|
||||||
|
|
||||||
|
// render() внутри очищает codeBlocks — сохраняем/восстанавливаем вокруг вызовов
|
||||||
|
auto renderKeep = [&](const QString &part) -> QString {
|
||||||
|
QHash<QString, QString> savedBlocks;
|
||||||
|
int savedCounter = 0;
|
||||||
|
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
|
||||||
|
QString html = render(part, vaultRoot, fileDir);
|
||||||
|
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto appendHtmlBlock = [&](const QString &html, const char *change) {
|
||||||
|
QVariantMap block;
|
||||||
|
block["type"] = "html";
|
||||||
|
block["html"] = html;
|
||||||
|
block["codeText"] = "";
|
||||||
|
block["language"] = "";
|
||||||
|
block["codeId"] = "";
|
||||||
|
block["highlightedText"] = "";
|
||||||
|
block["change"] = QString::fromLatin1(change);
|
||||||
|
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) {
|
||||||
|
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
||||||
|
m->codeBlocks.insert(codeId, code);
|
||||||
|
}
|
||||||
|
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
||||||
|
QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true)
|
||||||
|
: plainToRich(code);
|
||||||
|
if (tint)
|
||||||
|
highlighted = tintCodeHtml(highlighted, change[0] == 'd');
|
||||||
|
QVariantMap block;
|
||||||
|
block["type"] = "code";
|
||||||
|
block["codeText"] = code;
|
||||||
|
block["language"] = lang.isEmpty() ? "text" : lang;
|
||||||
|
block["codeId"] = codeId;
|
||||||
|
block["highlightedText"] = highlighted;
|
||||||
|
block["html"] = "";
|
||||||
|
block["change"] = QString::fromLatin1(change);
|
||||||
|
blocks.append(block);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Изменённый код (del-блок сразу за add-блоком): один блок с построчной
|
||||||
|
// подсветкой внутри — изменения видны там, где сделаны. Копируется новое.
|
||||||
|
auto appendMergedCode = [&](const QString &oldLang, const QString &oldCode,
|
||||||
|
const QString &newLang, const QString &newCode) -> bool {
|
||||||
|
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
||||||
|
const QString oldHl = (m && m->m_repo) ? hl.highlight(oldCode, oldLang, true)
|
||||||
|
: plainToRich(oldCode);
|
||||||
|
const QString newHl = (m && m->m_repo) ? hl.highlight(newCode, newLang, true)
|
||||||
|
: plainToRich(newCode);
|
||||||
|
const QStringList oldLines = oldCode.split(QLatin1Char('\n'));
|
||||||
|
const QStringList newLines = newCode.split(QLatin1Char('\n'));
|
||||||
|
const QStringList oldFrag = oldHl.split(QStringLiteral("<br/>"));
|
||||||
|
const QStringList newFrag = newHl.split(QStringLiteral("<br/>"));
|
||||||
|
if (oldFrag.size() != oldLines.size() || newFrag.size() != newLines.size())
|
||||||
|
return false; // fallback: два отдельных блока
|
||||||
|
const QVector<DiffOp> lops = diffMdBlocks(oldLines, newLines);
|
||||||
|
QStringList out;
|
||||||
|
QStringList pending;
|
||||||
|
auto flushPending = [&]() {
|
||||||
|
if (!pending.isEmpty()) {
|
||||||
|
out.append(pending.join(QStringLiteral("<br/>")));
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
int oi = 0, ni = 0;
|
||||||
|
for (const DiffOp &lop : lops) {
|
||||||
|
if (lop.kind == '=') {
|
||||||
|
pending.append(oldFrag[oi]);
|
||||||
|
++oi; ++ni;
|
||||||
|
} else {
|
||||||
|
flushPending();
|
||||||
|
const bool isDel = lop.kind == '-';
|
||||||
|
QString frag = isDel ? oldFrag[oi++] : newFrag[ni++];
|
||||||
|
if (frag.isEmpty())
|
||||||
|
frag = QStringLiteral("<br/>");
|
||||||
|
const QString mark = isDel
|
||||||
|
? QStringLiteral("<span style=\"color:#ff8f9e;font-weight:bold;\">\u2212 </span>")
|
||||||
|
: QStringLiteral("<span style=\"color:#4ade80;font-weight:bold;\">+ </span>");
|
||||||
|
const QString bg = isDel ? QStringLiteral("rgba(244,63,94,0.45)")
|
||||||
|
: QStringLiteral("rgba(34,197,94,0.45)");
|
||||||
|
out.append(QStringLiteral("<div style=\"background-color:%1;border-radius:4px;padding:0 6px;margin:1px 0;\">%2%3</div>").arg(bg, mark, frag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushPending();
|
||||||
|
const QString lang = !newLang.isEmpty() ? newLang : oldLang;
|
||||||
|
QString codeId;
|
||||||
|
if (m) {
|
||||||
|
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
||||||
|
m->codeBlocks.insert(codeId, newCode);
|
||||||
|
}
|
||||||
|
QVariantMap block;
|
||||||
|
block["type"] = "code";
|
||||||
|
block["codeText"] = newCode;
|
||||||
|
block["language"] = lang.isEmpty() ? "text" : lang;
|
||||||
|
block["codeId"] = codeId;
|
||||||
|
block["highlightedText"] = out.join(QString());
|
||||||
|
block["html"] = "";
|
||||||
|
block["change"] = "mod";
|
||||||
|
blocks.append(block);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QVector<DiffOp> ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd));
|
||||||
|
for (int k = 0; k < ops.size(); ++k) {
|
||||||
|
const DiffOp &op = ops[k];
|
||||||
|
const bool changed = op.kind != '=';
|
||||||
|
const bool isDel = op.kind == '-';
|
||||||
|
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)) {
|
||||||
|
++k;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isCode) {
|
||||||
|
appendCodeBlock(lang, code, change, changed);
|
||||||
|
} else {
|
||||||
|
QString html = renderKeep(op.text);
|
||||||
|
if (html.trimmed().isEmpty())
|
||||||
|
continue;
|
||||||
|
if (changed)
|
||||||
|
html = tintDiffHtml(html, isDel);
|
||||||
|
appendHtmlBlock(html, change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой результат при непустом вводе — отдать как один html-блок
|
||||||
|
if (blocks.isEmpty() && (!oldMd.trimmed().isEmpty() || !newMd.trimmed().isEmpty())) {
|
||||||
|
const QString src = !oldMd.trimmed().isEmpty() ? oldMd : newMd;
|
||||||
|
const QString html = renderKeep(src);
|
||||||
|
if (!html.trimmed().isEmpty())
|
||||||
|
appendHtmlBlock(html, "same");
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,14 @@ public:
|
|||||||
const QString &vaultRoot,
|
const QString &vaultRoot,
|
||||||
const QString &fileDir);
|
const QString &fileDir);
|
||||||
|
|
||||||
|
// Те же блоки, но версия oldMd с подсветкой отличий от newMd (для «Истории»).
|
||||||
|
// Каждый блок дополнительно имеет change:"same"/"del"/"add" (del — только в old,
|
||||||
|
// add — только в new); изменённые блоки обёрнуты в цветной div прямо в html/highlighted.
|
||||||
|
Q_INVOKABLE QVariantList parseBlocksWithDiff(const QString &oldMd,
|
||||||
|
const QString &newMd,
|
||||||
|
const QString &vaultRoot,
|
||||||
|
const QString &fileDir);
|
||||||
|
|
||||||
Q_INVOKABLE QString getCodeBlock(const QString &id) const;
|
Q_INVOKABLE QString getCodeBlock(const QString &id) const;
|
||||||
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
||||||
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
|
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
|
||||||
|
|||||||
@@ -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:#e6ffec") || !diffHtml.contains("background:#ffebe9")) return fail("diffHtml colors", diffHtml.left(500));
|
if(!diffHtml.contains("background:#b7f5c6") || !diffHtml.contains("background:#ffd6dd")) 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";
|
||||||
|
|||||||
@@ -134,6 +134,77 @@ int main(int argc, char *argv[])
|
|||||||
if (!hl2.contains("span")) return fail("hl2 no span", hl2.left(200));
|
if (!hl2.contains("span")) return fail("hl2 no span", hl2.left(200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подсветка: переносы строк — явные <br/>, иначе RichText склеит в одну строку
|
||||||
|
{
|
||||||
|
QString hl = p.highlightedCode("int a = 1;\nint b = 2;\n", "cpp");
|
||||||
|
if (!hl.contains("<br/>")) return fail("highlighted без <br/>", hl.left(200));
|
||||||
|
if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200));
|
||||||
|
QString plain = p.highlightedCode("a\nb", "неизвестный-язык-xyz");
|
||||||
|
if (!plain.contains("<br/>")) return fail("plain highlight без <br/>", plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBlocksWithDiff: версия old vs текущее new
|
||||||
|
{
|
||||||
|
const QString oldMd = "# Заголовок\n\nСтарая строка\n\n```cpp\nint x = 1;\n```\n";
|
||||||
|
const QString newMd = "# Заголовок\n\nНовая строка\n\n```cpp\nint x = 2;\n```\n";
|
||||||
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||||
|
if (d.isEmpty()) return fail("diff пуст");
|
||||||
|
// порядок как у стандартного diff: сначала все del изменённого куска, потом все add
|
||||||
|
auto changeOf = [&](int i) { return d[i].toMap().value("change").toString(); };
|
||||||
|
if (d.size() != 5) return fail("diff размер", QString::number(d.size()));
|
||||||
|
if (changeOf(0) != "same") return fail("diff[0] должен быть same", changeOf(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 (!d[1].toMap().value("html").toString().contains("ffd6dd")) return fail("diff[1] без розовой подсветки");
|
||||||
|
if (changeOf(2) != "del" || d[2].toMap().value("type").toString() != "code")
|
||||||
|
return fail("diff[2] код del", changeOf(2));
|
||||||
|
if (!d[2].toMap().value("highlightedText").toString().contains("244,63,94"))
|
||||||
|
return fail("diff[2] код без розовой подсветки");
|
||||||
|
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 (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code")
|
||||||
|
return fail("diff[4] код add", changeOf(4));
|
||||||
|
// кодовые блоки сохранили id для копирования
|
||||||
|
if (d[2].toMap().value("codeId").toString().isEmpty()) return fail("diff codeId пуст");
|
||||||
|
if (p.getCodeBlock(d[2].toMap().value("codeId").toString()) != "int x = 1;")
|
||||||
|
return fail("getCodeBlock после diff", p.getCodeBlock(d[2].toMap().value("codeId").toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBlocksWithDiff: идентичные тексты — всё same, без подсветки
|
||||||
|
{
|
||||||
|
const QString md = "# A\n\ntext\n";
|
||||||
|
auto d = p.parseBlocksWithDiff(md, md, vault, fileDir);
|
||||||
|
for (const auto &v : d) {
|
||||||
|
if (v.toMap().value("change").toString() != "same")
|
||||||
|
return fail("identical должен быть same");
|
||||||
|
if (v.toMap().value("html").toString().contains("ffd6dd")
|
||||||
|
|| v.toMap().value("html").toString().contains("b7f5c6"))
|
||||||
|
return fail("identical с подсветкой");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
||||||
|
{
|
||||||
|
const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";
|
||||||
|
const QString newMd = "# T\n\n```cpp\nint a = 1;\nint b = 3;\n```\n";
|
||||||
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||||
|
if (d.size() != 2) return fail("merge размер", QString::number(d.size()));
|
||||||
|
if (d[0].toMap().value("change").toString() != "same") return fail("merge[0] same");
|
||||||
|
if (d[1].toMap().value("type").toString() != "code"
|
||||||
|
|| d[1].toMap().value("change").toString() != "mod")
|
||||||
|
return fail("merge[1] должен быть code/mod", d[1].toMap().value("change").toString());
|
||||||
|
const QString hl = d[1].toMap().value("highlightedText").toString();
|
||||||
|
if (!hl.contains("244,63,94")) return fail("merge без розовой строки");
|
||||||
|
if (!hl.contains("34,197,94")) return fail("merge без зелёной строки");
|
||||||
|
// маркеры −/+ у изменённых строк (текст режется спанами подсветки, ищем маркеры)
|
||||||
|
if (!hl.contains(QChar(0x2212))) return fail("merge без маркера −");
|
||||||
|
if (!hl.contains("+ </span>")) return fail("merge без маркера +");
|
||||||
|
// копируется новое содержимое
|
||||||
|
const QString cid = d[1].toMap().value("codeId").toString();
|
||||||
|
if (cid.isEmpty()) return fail("merge codeId пуст");
|
||||||
|
if (!p.getCodeBlock(cid).contains("int b = 3;")) return fail("merge copy даёт не новое", p.getCodeBlock(cid));
|
||||||
|
}
|
||||||
|
|
||||||
std::printf("MDPARSER TEST OK\n");
|
std::printf("MDPARSER TEST OK\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user