Блоки кода: отдельный компонент 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:
@@ -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
|
||||
// обновить превью если нужно
|
||||
if (viewMode === "preview") {
|
||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл — начните писать в режиме редактирования") + "</i></p>" : rendered
|
||||
viewArea.markdown = textArea.text
|
||||
} else if (viewMode === "live") {
|
||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
livePreview.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Предпросмотр пуст") + "</i></p>" : rendered
|
||||
livePreview.markdown = textArea.text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,16 +40,9 @@ Item {
|
||||
if (_loadingViewMode) return
|
||||
saveViewModeForCurrentTab()
|
||||
if (viewMode === "preview") {
|
||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
if (rendered.trim().length === 0) {
|
||||
viewArea.text = "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл — начните писать в режиме редактирования") + "</i></p>"
|
||||
} else {
|
||||
viewArea.text = rendered
|
||||
}
|
||||
viewArea.markdown = textArea.text
|
||||
} else if (viewMode === "live") {
|
||||
const r = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
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>"
|
||||
livePreview.markdown = textArea.text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,7 +523,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Превью — виден в preview и live
|
||||
// Превью — виден в preview и live (отдельный компонент для блоков кода)
|
||||
ScrollView {
|
||||
id: viewScroll
|
||||
SplitView.fillWidth: true
|
||||
@@ -540,33 +531,23 @@ Item {
|
||||
visible: viewMode !== "edit" && documentManager.activeIndex >= 0
|
||||
clip: true
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
background: Rectangle { color: Theme.editorBg }
|
||||
|
||||
TextArea {
|
||||
MarkdownView {
|
||||
id: viewArea
|
||||
visible: viewMode === "preview"
|
||||
readOnly: true
|
||||
textFormat: Text.RichText
|
||||
color: Theme.text
|
||||
selectionColor: Theme.selectionBg
|
||||
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||
font.pixelSize: 14
|
||||
wrapMode: TextArea.Wrap
|
||||
background: Rectangle { color: Theme.editorBg }
|
||||
width: viewScroll.width
|
||||
vaultRoot: fileStore.vault
|
||||
fileDir: activeFileDir()
|
||||
onLinkActivated: (href) => handleLink(href)
|
||||
}
|
||||
|
||||
// Live preview отдельный TextArea чтобы не конфликтовать с обычным preview
|
||||
TextArea {
|
||||
MarkdownView {
|
||||
id: livePreview
|
||||
visible: viewMode === "live"
|
||||
readOnly: true
|
||||
textFormat: Text.RichText
|
||||
color: Theme.text
|
||||
selectionColor: Theme.selectionBg
|
||||
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||
font.pixelSize: 14
|
||||
wrapMode: TextArea.Wrap
|
||||
background: Rectangle { color: Theme.editorBg }
|
||||
width: viewScroll.width
|
||||
vaultRoot: fileStore.vault
|
||||
fileDir: activeFileDir()
|
||||
onLinkActivated: (href) => handleLink(href)
|
||||
}
|
||||
}
|
||||
@@ -594,8 +575,8 @@ Item {
|
||||
else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
|
||||
textArea.text = lines.join("\n")
|
||||
documentManager.updateContent(documentManager.activeIndex, textArea.text)
|
||||
if (viewMode === "preview") viewArea.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
else if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
if (viewMode === "preview") viewArea.markdown = textArea.text
|
||||
else if (viewMode === "live") livePreview.markdown = textArea.text
|
||||
}
|
||||
}
|
||||
} 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: 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 lastModifiedStr: ""
|
||||
@@ -686,16 +667,14 @@ Item {
|
||||
textArea.cursorPosition = Math.min(pos, c.length)
|
||||
}
|
||||
if (viewMode === "preview") {
|
||||
const rendered = mdParser.render(c, fileStore.vault, activeFileDir())
|
||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл — начните писать в режиме редактирования") + "</i></p>" : rendered
|
||||
viewArea.markdown = c
|
||||
} else if (viewMode === "live") {
|
||||
const rendered = mdParser.render(c, fileStore.vault, activeFileDir())
|
||||
livePreview.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Предпросмотр пуст") + "</i></p>" : rendered
|
||||
livePreview.markdown = c
|
||||
}
|
||||
} else {
|
||||
textArea.text = ""
|
||||
viewArea.text = ""
|
||||
livePreview.text = ""
|
||||
viewArea.markdown = ""
|
||||
livePreview.markdown = ""
|
||||
}
|
||||
refreshGitStatus()
|
||||
}
|
||||
@@ -887,7 +866,7 @@ Item {
|
||||
ToolTip.visible: hovered
|
||||
onClicked: {
|
||||
viewMode = "live"
|
||||
livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
livePreview.markdown = textArea.text
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
@@ -898,8 +877,7 @@ Item {
|
||||
ToolTip.text: qsTr("Просмотр (Ctrl+E)")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: {
|
||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл") + "</i></p>" : rendered
|
||||
viewArea.markdown = textArea.text
|
||||
viewMode = "preview"
|
||||
}
|
||||
}
|
||||
@@ -922,8 +900,7 @@ Item {
|
||||
} }
|
||||
Shortcut { sequence: "Ctrl+E"; onActivated: {
|
||||
if (viewMode === "edit") {
|
||||
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
viewArea.text = rendered.trim().length === 0 ? "<p style='color:" + Theme.textFaint + "'><i>" + qsTr("Пустой файл") + "</i></p>" : rendered
|
||||
viewArea.markdown = textArea.text
|
||||
viewMode = "preview"
|
||||
} else if (viewMode === "preview") {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user