Блоки кода: отдельный компонент CodeBlock + MarkdownView (Telegram-пузырь)
- CodeBlock.qml: отдельный Rectangle #2e4d6a radius 12, header с языком и кнопкой 📋 Копировать (hover rgba 0.22/active 0.30, border, radius 11), код в rgba(0,0,0,0.22) radius 8, JetBrains Mono 13/tab 4, syntax highlight тёмной темой, selectByMouse
- MarkdownView.qml: ScrollView+Column+Repeater, parseBlocks() разбивает markdown на html/code блоки, html через Text RichText, code через CodeBlock, copyRequested → mdParser.copyToClipboard
- MdParser: parseBlocks() regex ```lang\n.*?\n``` + render для html частей, codeId/highlighted, highlightedCode(), highlight(...,true) forceDark, QTime удалён, QRegularExpression добавлен
- EditorPanel: viewArea/livePreview теперь MarkdownView (markdown property), liveDebounce/markdown, onLinkActivated → handleLink, viewMode переключает markdown
- CMake: добавлены CodeBlock.qml/MarkdownView.qml в qml модуль
- Исправлены qmllint: rgba → Qt.rgba, lineHeight убран, model.type → required property
This commit is contained in:
@@ -122,6 +122,8 @@ qt_add_qml_module(flora
|
|||||||
qml/panels/TagChip.qml
|
qml/panels/TagChip.qml
|
||||||
qml/panels/GraphView.qml
|
qml/panels/GraphView.qml
|
||||||
qml/panels/MonthCalendar.qml
|
qml/panels/MonthCalendar.qml
|
||||||
|
qml/panels/CodeBlock.qml
|
||||||
|
qml/panels/MarkdownView.qml
|
||||||
qml/wizard/WelcomeWizard.qml
|
qml/wizard/WelcomeWizard.qml
|
||||||
qml/dialogs/ProviderSetupGuide.qml
|
qml/dialogs/ProviderSetupGuide.qml
|
||||||
qml/dialogs/NameDialog.qml
|
qml/dialogs/NameDialog.qml
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Flora
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string code: ""
|
||||||
|
property string language: "text"
|
||||||
|
property string codeId: ""
|
||||||
|
|
||||||
|
signal copyRequested(string codeId)
|
||||||
|
|
||||||
|
// Telegram-like bubble
|
||||||
|
color: "#2e4d6a"
|
||||||
|
radius: 12
|
||||||
|
// adaptive width like Telegram message
|
||||||
|
width: parent ? Math.min(parent.width * 0.88, implicitWidth) : implicitWidth
|
||||||
|
implicitWidth: 400
|
||||||
|
implicitHeight: column.implicitHeight + 20
|
||||||
|
|
||||||
|
// For syntax highlighting, we use a TextArea with RichText
|
||||||
|
// The highlighted code is set from outside via highlightedText
|
||||||
|
property string highlightedText: ""
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
// Header with language and copy button
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
height: 24
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: root.language.toUpperCase()
|
||||||
|
color: "#8a9ba8"
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.4
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
height: parent.height
|
||||||
|
visible: root.language !== "text" && root.language !== ""
|
||||||
|
}
|
||||||
|
Item { width: parent.width - languageLabel.width - copyButton.width - 16; height: 1 }
|
||||||
|
Label {
|
||||||
|
id: languageLabel
|
||||||
|
text: root.language
|
||||||
|
visible: false
|
||||||
|
width: 0
|
||||||
|
height: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy button - Telegram style, hover/active feedback
|
||||||
|
Rectangle {
|
||||||
|
id: copyButton
|
||||||
|
width: copyLabel.implicitWidth + 16
|
||||||
|
height: 22
|
||||||
|
radius: 11
|
||||||
|
color: copyHover.hovered ? (copyTap.pressed ? Qt.rgba(1,1,1,0.30) : Qt.rgba(1,1,1,0.22)) : Qt.rgba(1,1,1,0.12)
|
||||||
|
border.color: copyHover.hovered ? Qt.rgba(1,1,1,0.28) : Qt.rgba(1,1,1,0.18)
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
property bool copied: false
|
||||||
|
Timer { id: copyResetTimer; interval: 1500; onTriggered: copyButton.copied = false }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: copyLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: copyButton.copied ? "✓ Скопировано" : "📋 Копировать"
|
||||||
|
color: copyButton.copied ? "#6ab2f6" : "#ffffff"
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.family: "-apple-system, sans-serif"
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler { id: copyHover }
|
||||||
|
TapHandler {
|
||||||
|
id: copyTap
|
||||||
|
onTapped: {
|
||||||
|
root.copyRequested(root.codeId)
|
||||||
|
copyButton.copied = true
|
||||||
|
copyResetTimer.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For mouse cursor
|
||||||
|
HoverHandler {
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code area - like real editor
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
color: Qt.rgba(0,0,0,0.22)
|
||||||
|
radius: 8
|
||||||
|
// height based on content
|
||||||
|
implicitHeight: codeText.implicitHeight + 20
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
clip: true
|
||||||
|
ScrollBar.horizontal.policy: ScrollBar.AsNeeded
|
||||||
|
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: codeText
|
||||||
|
readOnly: true
|
||||||
|
selectByMouse: true
|
||||||
|
textFormat: Text.RichText
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#ffffff"
|
||||||
|
selectionColor: "#3a6ea5"
|
||||||
|
selectedTextColor: "#ffffff"
|
||||||
|
font.family: "JetBrains Mono, Fira Code, Menlo, Consolas, monospace"
|
||||||
|
font.pixelSize: 13
|
||||||
|
tabStopDistance: 32
|
||||||
|
text: root.highlightedText.length > 0 ? root.highlightedText : root.code
|
||||||
|
background: Rectangle { color: "transparent" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For bracket matching highlight in editor, we could add a similar indicator
|
||||||
|
// But for preview, this is just display
|
||||||
|
}
|
||||||
+24
-47
@@ -22,11 +22,9 @@ Item {
|
|||||||
_loadingViewMode = false
|
_loadingViewMode = false
|
||||||
// обновить превью если нужно
|
// обновить превью если нужно
|
||||||
if (viewMode === "preview") {
|
if (viewMode === "preview") {
|
||||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
viewArea.markdown = textArea.text
|
||||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл — начните писать в режиме редактирования") + "</i></p>" : rendered
|
|
||||||
} else if (viewMode === "live") {
|
} else if (viewMode === "live") {
|
||||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
livePreview.markdown = textArea.text
|
||||||
livePreview.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Предпросмотр пуст") + "</i></p>" : rendered
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,16 +40,9 @@ Item {
|
|||||||
if (_loadingViewMode) return
|
if (_loadingViewMode) return
|
||||||
saveViewModeForCurrentTab()
|
saveViewModeForCurrentTab()
|
||||||
if (viewMode === "preview") {
|
if (viewMode === "preview") {
|
||||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
viewArea.markdown = textArea.text
|
||||||
if (rendered.trim().length === 0) {
|
|
||||||
viewArea.text = "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл — начните писать в режиме редактирования") + "</i></p>"
|
|
||||||
} else {
|
|
||||||
viewArea.text = rendered
|
|
||||||
}
|
|
||||||
} else if (viewMode === "live") {
|
} else if (viewMode === "live") {
|
||||||
const r = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
livePreview.markdown = textArea.text
|
||||||
livePreview.text = r.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Предпросмотр пуст") + "</i></p>" : r
|
|
||||||
if (livePreview.text.trim().length === 0) livePreview.text = "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Предпросмотр пуст") + "</i></p>"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,7 +523,7 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Превью — виден в preview и live
|
// Превью — виден в preview и live (отдельный компонент для блоков кода)
|
||||||
ScrollView {
|
ScrollView {
|
||||||
id: viewScroll
|
id: viewScroll
|
||||||
SplitView.fillWidth: true
|
SplitView.fillWidth: true
|
||||||
@@ -540,33 +531,23 @@ Item {
|
|||||||
visible: viewMode !== "edit" && documentManager.activeIndex >= 0
|
visible: viewMode !== "edit" && documentManager.activeIndex >= 0
|
||||||
clip: true
|
clip: true
|
||||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||||
|
background: Rectangle { color: Theme.editorBg }
|
||||||
|
|
||||||
TextArea {
|
MarkdownView {
|
||||||
id: viewArea
|
id: viewArea
|
||||||
visible: viewMode === "preview"
|
visible: viewMode === "preview"
|
||||||
readOnly: true
|
width: viewScroll.width
|
||||||
textFormat: Text.RichText
|
vaultRoot: fileStore.vault
|
||||||
color: Theme.text
|
fileDir: activeFileDir()
|
||||||
selectionColor: Theme.selectionBg
|
|
||||||
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
|
||||||
font.pixelSize: 14
|
|
||||||
wrapMode: TextArea.Wrap
|
|
||||||
background: Rectangle { color: Theme.editorBg }
|
|
||||||
onLinkActivated: (href) => handleLink(href)
|
onLinkActivated: (href) => handleLink(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live preview отдельный TextArea чтобы не конфликтовать с обычным preview
|
MarkdownView {
|
||||||
TextArea {
|
|
||||||
id: livePreview
|
id: livePreview
|
||||||
visible: viewMode === "live"
|
visible: viewMode === "live"
|
||||||
readOnly: true
|
width: viewScroll.width
|
||||||
textFormat: Text.RichText
|
vaultRoot: fileStore.vault
|
||||||
color: Theme.text
|
fileDir: activeFileDir()
|
||||||
selectionColor: Theme.selectionBg
|
|
||||||
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
|
||||||
font.pixelSize: 14
|
|
||||||
wrapMode: TextArea.Wrap
|
|
||||||
background: Rectangle { color: Theme.editorBg }
|
|
||||||
onLinkActivated: (href) => handleLink(href)
|
onLinkActivated: (href) => handleLink(href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,8 +575,8 @@ Item {
|
|||||||
else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
|
else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
|
||||||
textArea.text = lines.join("\n")
|
textArea.text = lines.join("\n")
|
||||||
documentManager.updateContent(documentManager.activeIndex, textArea.text)
|
documentManager.updateContent(documentManager.activeIndex, textArea.text)
|
||||||
if (viewMode === "preview") viewArea.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
if (viewMode === "preview") viewArea.markdown = textArea.text
|
||||||
else if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
else if (viewMode === "live") livePreview.markdown = textArea.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (href.startsWith("flora://open/")) {
|
} else if (href.startsWith("flora://open/")) {
|
||||||
@@ -653,7 +634,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Timer { id: editDebounce; interval: 250; onTriggered: documentManager.updateContent(documentManager.activeIndex, textArea.text) }
|
Timer { id: editDebounce; interval: 250; onTriggered: documentManager.updateContent(documentManager.activeIndex, textArea.text) }
|
||||||
Timer { id: liveDebounce; interval: 150; onTriggered: { if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) } }
|
Timer { id: liveDebounce; interval: 150; onTriggered: { if (viewMode === "live") livePreview.markdown = textArea.text } }
|
||||||
|
|
||||||
property string gitStatusText: ""
|
property string gitStatusText: ""
|
||||||
property string lastModifiedStr: ""
|
property string lastModifiedStr: ""
|
||||||
@@ -686,16 +667,14 @@ Item {
|
|||||||
textArea.cursorPosition = Math.min(pos, c.length)
|
textArea.cursorPosition = Math.min(pos, c.length)
|
||||||
}
|
}
|
||||||
if (viewMode === "preview") {
|
if (viewMode === "preview") {
|
||||||
const rendered = mdParser.render(c, fileStore.vault, activeFileDir())
|
viewArea.markdown = c
|
||||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл — начните писать в режиме редактирования") + "</i></p>" : rendered
|
|
||||||
} else if (viewMode === "live") {
|
} else if (viewMode === "live") {
|
||||||
const rendered = mdParser.render(c, fileStore.vault, activeFileDir())
|
livePreview.markdown = c
|
||||||
livePreview.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Предпросмотр пуст") + "</i></p>" : rendered
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
textArea.text = ""
|
textArea.text = ""
|
||||||
viewArea.text = ""
|
viewArea.markdown = ""
|
||||||
livePreview.text = ""
|
livePreview.markdown = ""
|
||||||
}
|
}
|
||||||
refreshGitStatus()
|
refreshGitStatus()
|
||||||
}
|
}
|
||||||
@@ -887,7 +866,7 @@ Item {
|
|||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
onClicked: {
|
onClicked: {
|
||||||
viewMode = "live"
|
viewMode = "live"
|
||||||
livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
livePreview.markdown = textArea.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolButton {
|
ToolButton {
|
||||||
@@ -898,8 +877,7 @@ Item {
|
|||||||
ToolTip.text: qsTr("Просмотр (Ctrl+E)")
|
ToolTip.text: qsTr("Просмотр (Ctrl+E)")
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
onClicked: {
|
onClicked: {
|
||||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
viewArea.markdown = textArea.text
|
||||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл") + "</i></p>" : rendered
|
|
||||||
viewMode = "preview"
|
viewMode = "preview"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -922,8 +900,7 @@ Item {
|
|||||||
} }
|
} }
|
||||||
Shortcut { sequence: "Ctrl+E"; onActivated: {
|
Shortcut { sequence: "Ctrl+E"; onActivated: {
|
||||||
if (viewMode === "edit") {
|
if (viewMode === "edit") {
|
||||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
viewArea.markdown = textArea.text
|
||||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл") + "</i></p>" : rendered
|
|
||||||
viewMode = "preview"
|
viewMode = "preview"
|
||||||
} else if (viewMode === "preview") {
|
} else if (viewMode === "preview") {
|
||||||
viewMode = "edit"
|
viewMode = "edit"
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Flora
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string markdown: ""
|
||||||
|
property string vaultRoot: ""
|
||||||
|
property string fileDir: ""
|
||||||
|
implicitHeight: column.implicitHeight + 24
|
||||||
|
|
||||||
|
signal linkActivated(string href)
|
||||||
|
|
||||||
|
// Для обновления
|
||||||
|
function update() {
|
||||||
|
// Триггерим пересборку блоков
|
||||||
|
blocksModel.clear()
|
||||||
|
const blocks = mdParser.parseBlocks(markdown, vaultRoot, fileDir)
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
blocksModel.append(blocks[i])
|
||||||
|
}
|
||||||
|
// Позиция скролла в начало
|
||||||
|
// contentItem.contentY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onMarkdownChanged: update()
|
||||||
|
onVaultRootChanged: update()
|
||||||
|
onFileDirChanged: update()
|
||||||
|
|
||||||
|
Component.onCompleted: update()
|
||||||
|
|
||||||
|
ListModel { id: blocksModel }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
// padding via anchors
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: blocksModel
|
||||||
|
delegate: Item {
|
||||||
|
required property int index
|
||||||
|
required property string type
|
||||||
|
required property string html
|
||||||
|
required property string code
|
||||||
|
required property string language
|
||||||
|
required property string codeId
|
||||||
|
required property string highlighted
|
||||||
|
width: parent.width
|
||||||
|
height: childrenRect.height
|
||||||
|
// HTML block
|
||||||
|
Text {
|
||||||
|
visible: type === "html" && html
|
||||||
|
width: parent.width
|
||||||
|
text: html || ""
|
||||||
|
textFormat: Text.RichText
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: Theme.text
|
||||||
|
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||||
|
font.pixelSize: 14
|
||||||
|
onLinkActivated: (href) => root.linkActivated(href)
|
||||||
|
}
|
||||||
|
// Code block - separate component
|
||||||
|
CodeBlock {
|
||||||
|
visible: type === "code"
|
||||||
|
width: parent.width
|
||||||
|
code: code || ""
|
||||||
|
language: language || "text"
|
||||||
|
codeId: codeId || ""
|
||||||
|
highlightedText: highlighted || ""
|
||||||
|
onCopyRequested: (id) => {
|
||||||
|
const txt = mdParser.getCodeBlock(id)
|
||||||
|
if (txt.length > 0) {
|
||||||
|
mdParser.copyToClipboard(txt)
|
||||||
|
root.linkActivated("flora://copycode/" + id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
width: parent.width
|
||||||
|
text: markdown.trim().length === 0 ? qsTr("Пустой файл — начните писать в режиме редактирования") : ""
|
||||||
|
color: Theme.textFaint
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
font.italic: true
|
||||||
|
visible: markdown.trim().length === 0 && blocksModel.count === 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QImageReader>
|
#include <QImageReader>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QTime>
|
#include <QTime>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
@@ -736,4 +737,81 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
|||||||
cmark_node_free(doc);
|
cmark_node_free(doc);
|
||||||
cmark_parser_free(parser);
|
cmark_parser_free(parser);
|
||||||
return html;
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vaultRoot, const QString &fileDir)
|
||||||
|
{
|
||||||
|
QVariantList blocks;
|
||||||
|
if (markdown.isEmpty()) return blocks;
|
||||||
|
|
||||||
|
// Сбрасываем счётчик для нового парса
|
||||||
|
if (m) {
|
||||||
|
m->codeBlocks.clear();
|
||||||
|
m->codeCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем fenced code blocks ```lang\ncode\n```
|
||||||
|
static const QRegularExpression re(QStringLiteral("```([^`\\n]*?)\\n(.*?)\\n```"), QRegularExpression::DotMatchesEverythingOption);
|
||||||
|
QRegularExpressionMatchIterator it = re.globalMatch(markdown);
|
||||||
|
int lastPos = 0;
|
||||||
|
while (it.hasNext()) {
|
||||||
|
QRegularExpressionMatch match = it.next();
|
||||||
|
int start = match.capturedStart();
|
||||||
|
int end = match.capturedEnd();
|
||||||
|
// Текст до блока
|
||||||
|
if (start > lastPos) {
|
||||||
|
QString htmlPart = markdown.mid(lastPos, start - lastPos);
|
||||||
|
if (!htmlPart.trimmed().isEmpty()) {
|
||||||
|
QString html = render(htmlPart, vaultRoot, fileDir);
|
||||||
|
if (!html.trimmed().isEmpty())
|
||||||
|
blocks.append(QVariantMap{{"type", "html"}, {"html", html}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QString langRaw = match.captured(1).trimmed();
|
||||||
|
QString code = match.captured(2);
|
||||||
|
QString lang = langRaw; // нормализация внутри highlight
|
||||||
|
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;
|
||||||
|
if (m && m->m_repo) {
|
||||||
|
highlighted = hl.highlight(code, lang, true);
|
||||||
|
} else {
|
||||||
|
highlighted = code.toHtmlEscaped();
|
||||||
|
}
|
||||||
|
QVariantMap block;
|
||||||
|
block["type"] = "code";
|
||||||
|
block["code"] = code;
|
||||||
|
block["language"] = lang.isEmpty() ? "text" : lang;
|
||||||
|
block["codeId"] = codeId;
|
||||||
|
block["highlighted"] = highlighted;
|
||||||
|
blocks.append(block);
|
||||||
|
lastPos = end;
|
||||||
|
}
|
||||||
|
// Остаток после последнего блока
|
||||||
|
if (lastPos < markdown.length()) {
|
||||||
|
QString htmlPart = markdown.mid(lastPos);
|
||||||
|
if (!htmlPart.trimmed().isEmpty()) {
|
||||||
|
QString html = render(htmlPart, vaultRoot, fileDir);
|
||||||
|
if (!html.trimmed().isEmpty())
|
||||||
|
blocks.append(QVariantMap{{"type", "html"}, {"html", html}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если вообще не нашли блоки, но markdown не пустой — вернём как html
|
||||||
|
if (blocks.isEmpty() && !markdown.trimmed().isEmpty()) {
|
||||||
|
QString html = render(markdown, vaultRoot, fileDir);
|
||||||
|
blocks.append(QVariantMap{{"type", "html"}, {"html", html}});
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString MdParser::highlightedCode(const QString &code, const QString &language) const
|
||||||
|
{
|
||||||
|
QString lang = language.trimmed();
|
||||||
|
if (!m || !m->m_repo) return code.toHtmlEscaped();
|
||||||
|
CodeHighlighter hl(m->m_repo);
|
||||||
|
return hl.highlight(code, lang, true);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QVariantList>
|
||||||
|
|
||||||
// Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра.
|
// Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра.
|
||||||
// Задачи/ссылки/переходы кодируются ссылками schemes:
|
// Задачи/ссылки/переходы кодируются ссылками schemes:
|
||||||
@@ -21,8 +22,15 @@ public:
|
|||||||
const QString &vaultRoot,
|
const QString &vaultRoot,
|
||||||
const QString &fileDir);
|
const QString &fileDir);
|
||||||
|
|
||||||
|
// Разбор на блоки для отдельного отображения кодовых блоков (CodeBlock.qml)
|
||||||
|
// Возвращает список {type:"html"/"code", html/code/language/codeId/highlighted}
|
||||||
|
Q_INVOKABLE QVariantList parseBlocks(const QString &markdown,
|
||||||
|
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;
|
||||||
|
|
||||||
struct Impl;
|
struct Impl;
|
||||||
private:
|
private:
|
||||||
|
|||||||
Reference in New Issue
Block a user