diff --git a/CMakeLists.txt b/CMakeLists.txt index efb6863..d01efe0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -122,6 +122,8 @@ qt_add_qml_module(flora qml/panels/TagChip.qml qml/panels/GraphView.qml qml/panels/MonthCalendar.qml + qml/panels/CodeBlock.qml + qml/panels/MarkdownView.qml qml/wizard/WelcomeWizard.qml qml/dialogs/ProviderSetupGuide.qml qml/dialogs/NameDialog.qml diff --git a/qml/panels/CodeBlock.qml b/qml/panels/CodeBlock.qml new file mode 100644 index 0000000..b931dde --- /dev/null +++ b/qml/panels/CodeBlock.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 +} diff --git a/qml/panels/EditorPanel.qml b/qml/panels/EditorPanel.qml index 6e1dc70..a42b82c 100644 --- a/qml/panels/EditorPanel.qml +++ b/qml/panels/EditorPanel.qml @@ -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 ? "
" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" : rendered + viewArea.markdown = textArea.text } else if (viewMode === "live") { - const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) - livePreview.text = rendered.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : 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 = "" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" - } 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 ? "" + qsTr("Предпросмотр пуст") + "
" : r - if (livePreview.text.trim().length === 0) livePreview.text = "" + qsTr("Предпросмотр пуст") + "
" + 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 ? "" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" : rendered + viewArea.markdown = c } else if (viewMode === "live") { - const rendered = mdParser.render(c, fileStore.vault, activeFileDir()) - livePreview.text = rendered.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : 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 ? "" + qsTr("Пустой файл") + "
" : 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 ? "" + qsTr("Пустой файл") + "
" : rendered + viewArea.markdown = textArea.text viewMode = "preview" } else if (viewMode === "preview") { viewMode = "edit" diff --git a/qml/panels/MarkdownView.qml b/qml/panels/MarkdownView.qml new file mode 100644 index 0000000..a40edbe --- /dev/null +++ b/qml/panels/MarkdownView.qml @@ -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 + } + } +} diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp index 92060de..b13f6a9 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -22,6 +22,7 @@ #include