diff --git a/src/controls/settingsdialog.cpp b/src/controls/settingsdialog.cpp index 2aed6344..513677f5 100644 --- a/src/controls/settingsdialog.cpp +++ b/src/controls/settingsdialog.cpp @@ -41,6 +41,7 @@ void GenerateSettingTranslate() auto shortcuts_window_escapeName = QObject::tr("Exit"); auto shortcuts_window_displayshortcutsName = QObject::tr("Display shortcuts"); auto shortcuts_window_printName = QObject::tr("Print"); + auto shortcuts_window_togglemarkdownpreviewName = QObject::tr("Toggle markdown preview"); auto group_editorName = QObject::tr("Editor"); auto shortcuts_editor_indentlineName = QObject::tr("Increase indent"); auto shortcuts_editor_backindentlineName = QObject::tr("Decrease indent"); diff --git a/src/editor/editwrapper.cpp b/src/editor/editwrapper.cpp index cf099fc6..8b519cc4 100644 --- a/src/editor/editwrapper.cpp +++ b/src/editor/editwrapper.cpp @@ -4,6 +4,7 @@ #include "editwrapper.h" #include "leftareaoftextedit.h" +#include "markdownpreview.h" #include "drecentmanager.h" #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include @@ -103,16 +105,41 @@ EditWrapper::EditWrapper(Window *window, QWidget *parent) m_pTextEdit->setAccessibleName("TextEditor"); m_pBottomBar->setAccessibleName("EditorBottomBar"); // Init layout and widgets. - QHBoxLayout *m_layout = new QHBoxLayout; m_pLeftAreaTextEdit = m_pTextEdit->getLeftAreaWidget(); - m_layout->setContentsMargins(0, 0, 0, 0); - m_layout->setSpacing(0); - m_layout->addWidget(m_pLeftAreaTextEdit); - m_layout->addWidget(m_pTextEdit); m_pTextEdit->setWrapper(this); + // 编辑器容器:行号/标记区 + 文本编辑区,保持原有布局 + QWidget *editorContainer = new QWidget(this); + QHBoxLayout *editorLayout = new QHBoxLayout(editorContainer); + editorLayout->setContentsMargins(0, 0, 0, 0); + editorLayout->setSpacing(0); + editorLayout->addWidget(m_pLeftAreaTextEdit); + editorLayout->addWidget(m_pTextEdit); + + // 编辑区与 Markdown 预览分栏,默认只显示编辑区 + m_pEditSplitter = new QSplitter(Qt::Horizontal, this); + m_pEditSplitter->setObjectName("EditSplitter"); + m_pEditSplitter->setChildrenCollapsible(false); + m_pEditSplitter->setHandleWidth(1); + m_pEditSplitter->addWidget(editorContainer); + m_pEditSplitter->setStretchFactor(0, 1); + + // Qt5 构建下预览不可用(QTextDocument::setMarkdown 需要 Qt >= 6.5) + if (MarkdownPreview::isSupported()) { + m_pMarkdownPreview = new MarkdownPreview(this); + m_pMarkdownPreview->setVisible(false); + m_pEditSplitter->addWidget(m_pMarkdownPreview); + m_pEditSplitter->setStretchFactor(1, 1); + // 编辑内容变化时实时刷新预览 + connect(m_pTextEdit, &QPlainTextEdit::textChanged, this, [this]() { + if (m_pMarkdownPreview != nullptr && m_pMarkdownPreview->isVisible()) { + m_pMarkdownPreview->updatePreview(m_pTextEdit->toPlainText()); + } + }); + } + QVBoxLayout *mainLayout = new QVBoxLayout; - mainLayout->addLayout(m_layout); + mainLayout->addWidget(m_pEditSplitter); mainLayout->addWidget(m_pBottomBar); mainLayout->setContentsMargins(0, 0, 0, 0); mainLayout->setSpacing(0); @@ -780,6 +807,10 @@ void EditWrapper::updatePath(const QString &file, QString qstrTruePath) qDebug() << "EditWrapper updatePath, m_pTextEdit->setFilePath(file)"; m_pTextEdit->setTruePath(qstrTruePath); qDebug() << "EditWrapper updatePath, m_pTextEdit->setTruePath(qstrTruePath)"; + // Markdown 文件打开时自动显示可视化预览 + if (m_pMarkdownPreview != nullptr) { + setMarkdownPreviewVisible(MarkdownPreview::isMarkdownFile(file)); + } } bool EditWrapper::isModified() @@ -1482,6 +1513,27 @@ QString EditWrapper::filePath() return m_pTextEdit->getFilePath(); } +void EditWrapper::setMarkdownPreviewVisible(bool visible) +{ + if (m_pMarkdownPreview == nullptr) { + return; + } + m_pMarkdownPreview->setVisible(visible); + if (visible) { + m_pMarkdownPreview->updatePreview(m_pTextEdit->toPlainText()); + // 保证初次显示时左右分栏比例合理(编辑区 55% / 预览 45%) + if (m_pEditSplitter != nullptr && m_pEditSplitter->width() > 0) { + const int total = m_pEditSplitter->width(); + m_pEditSplitter->setSizes({total * 55 / 100, total * 45 / 100}); + } + } +} + +bool EditWrapper::isMarkdownPreviewVisible() const +{ + return m_pMarkdownPreview != nullptr && m_pMarkdownPreview->isVisible(); +} + TextEdit *EditWrapper::textEditor() { qDebug() << "EditWrapper textEditor"; diff --git a/src/editor/editwrapper.h b/src/editor/editwrapper.h index 1678b1c0..7800c299 100644 --- a/src/editor/editwrapper.h +++ b/src/editor/editwrapper.h @@ -26,6 +26,8 @@ #include class Window; +class MarkdownPreview; +class QSplitter; class EditWrapper : public QWidget { Q_OBJECT @@ -108,6 +110,10 @@ class EditWrapper : public QWidget Window *window(); void updateHighlighterAll(); + // Markdown 可视化预览开关(Qt5 构建下预览不可用,调用无效果) + void setMarkdownPreviewVisible(bool visible); + bool isMarkdownPreviewVisible() const; + //get and set m_tModifiedDateTime QDateTime getLastModifiedTime() const; void setLastModifiedTime(const QString &time); @@ -175,6 +181,10 @@ public slots: BottomBar *m_pBottomBar = nullptr; // WarningNotices *m_pWaringNotices = nullptr; + // + MarkdownPreview *m_pMarkdownPreview = nullptr; + // 编辑区与 Markdown 预览的分栏容器 + QSplitter *m_pEditSplitter = nullptr; QDateTime m_tModifiedDateTime; //退出 diff --git a/src/editor/markdownpreview.cpp b/src/editor/markdownpreview.cpp new file mode 100644 index 00000000..2411e026 --- /dev/null +++ b/src/editor/markdownpreview.cpp @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "markdownpreview.h" + +#include +#include +#include + +MarkdownPreview::MarkdownPreview(QWidget *parent) + : QTextBrowser(parent) +{ + setAccessibleName("MarkdownPreviewView"); + setFrameShape(QFrame::NoFrame); + setOpenLinks(false); + setOpenExternalLinks(false); + setReadOnly(true); +} + +bool MarkdownPreview::isSupported() +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + return true; +#else + return false; +#endif +} + +bool MarkdownPreview::isMarkdownFile(const QString &filePath) +{ + const QString suffix = QFileInfo(filePath).suffix().toLower(); + return suffix == QStringLiteral("md") || suffix == QStringLiteral("markdown"); +} + +void MarkdownPreview::updatePreview(const QString &markdown) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + QTextDocument *newDoc = new QTextDocument(this); + // 主题无关的基础样式:标题分级 + 等宽代码,颜色随应用调色板自适应 + newDoc->setDefaultStyleSheet(QStringLiteral( + "h1 { font-size: 26px; font-weight: 600; }" + "h2 { font-size: 22px; font-weight: 600; }" + "h3 { font-size: 19px; font-weight: 600; }" + "h4 { font-size: 16px; font-weight: 600; }" + "h5 { font-size: 14px; font-weight: 600; }" + "h6 { font-size: 13px; font-weight: 600; }" + "code, pre { font-family: 'monospace'; }" + "blockquote { margin-left: 16px; }")); + newDoc->setMarkdown(markdown); + + // setDocument 会接管新文档(父对象为 this)并释放上一份文档,无需手动 delete + setDocument(newDoc); +#else + Q_UNUSED(markdown) + setPlainText(markdown); +#endif +} + +void MarkdownPreview::doSetSource(const QUrl &url, QTextDocument::ResourceType type) +{ + Q_UNUSED(url) + Q_UNUSED(type) + // 预览只读,不进行链接跳转 +} diff --git a/src/editor/markdownpreview.h b/src/editor/markdownpreview.h new file mode 100644 index 00000000..1b28a650 --- /dev/null +++ b/src/editor/markdownpreview.h @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef MARKDOWNPREVIEW_H +#define MARKDOWNPREVIEW_H + +#include + +/** + * @brief Markdown 可视化预览控件 + * + * 将 markdown 文本实时渲染为富文本展示,基于 QTextDocument::setMarkdown(Qt >= 6.5)。 + * Qt5 构建下预览功能整体禁用(isSupported() 返回 false)。 + */ +class MarkdownPreview : public QTextBrowser +{ + Q_OBJECT + +public: + explicit MarkdownPreview(QWidget *parent = nullptr); + + // 当前构建是否支持 markdown 预览(需要 Qt >= 6.5) + static bool isSupported(); + + // 是否为可预览的 markdown 文件(.md / .markdown) + static bool isMarkdownFile(const QString &filePath); + + // 以 markdown 内容刷新预览 + void updatePreview(const QString &markdown); + +protected: + // 预览为只读展示,忽略超链接跳转 + void doSetSource(const QUrl &url, QTextDocument::ResourceType type = QTextDocument::UnknownResource) override; +}; + +#endif // MARKDOWNPREVIEW_H diff --git a/src/resources/settings.json b/src/resources/settings.json index 3272ee11..bae09dfb 100644 --- a/src/resources/settings.json +++ b/src/resources/settings.json @@ -238,6 +238,12 @@ "name": "Print", "type": "keySequenceEdit", "default": "Ctrl+P" + }, + { + "key": "togglemarkdownpreview", + "name": "Toggle markdown preview", + "type": "keySequenceEdit", + "default": "Ctrl+Shift+M" } ] }, diff --git a/src/resources/settings.json.in b/src/resources/settings.json.in index 953289dc..2650f089 100644 --- a/src/resources/settings.json.in +++ b/src/resources/settings.json.in @@ -238,6 +238,12 @@ "name": "Print", "type": "keySequenceEdit", "default": "Ctrl+P" + }, + { + "key": "togglemarkdownpreview", + "name": "Toggle markdown preview", + "type": "keySequenceEdit", + "default": "Ctrl+Shift+M" } ] }, diff --git a/src/widgets/window.cpp b/src/widgets/window.cpp index f29c7ee2..a65c1358 100644 --- a/src/widgets/window.cpp +++ b/src/widgets/window.cpp @@ -4,6 +4,7 @@ #include "window.h" #include "pathsettintwgt.h" +#include "../editor/markdownpreview.h" #include #include #include @@ -477,6 +478,8 @@ void Window::updateSaveAsFileName(QString strOldFilePath, QString strNewFilePath m_wrappers.remove(strOldFilePath); m_wrappers.insert(strNewFilePath, wrapper); + // 另存为可能改变文件类型,刷新 Markdown 预览开关状态 + updateMarkdownPreviewActionState(); qDebug() << "updateSaveAsFileName end"; } @@ -539,6 +542,11 @@ void Window::initTitlebar() saveAsAction->setObjectName("SaveAs"); QAction *printAction(new QAction(tr("Print"), this)); printAction->setObjectName("Print"); + QAction *markdownPreviewAction(new QAction(tr("Markdown preview"), this)); + markdownPreviewAction->setObjectName("MarkdownPreview"); + markdownPreviewAction->setCheckable(true); + markdownPreviewAction->setEnabled(false); + m_markdownPreviewAction = markdownPreviewAction; QAction *switchThemeAction(new QAction(tr("Switch theme"), this)); switchThemeAction->setObjectName("SwitchTheme"); QAction *settingAction(new QAction(tr("Settings"), this)); @@ -557,6 +565,7 @@ void Window::initTitlebar() m_menu->addAction(saveAction); m_menu->addAction(saveAsAction); m_menu->addAction(printAction); + m_menu->addAction(markdownPreviewAction); //此接口不可删除,预留的编辑器内部主题选择接口 //m_menu->addAction(switchThemeAction); m_menu->addSeparator(); @@ -627,6 +636,7 @@ void Window::initTitlebar() connect(saveAction, &QAction::triggered, this, &Window::saveFile); connect(saveAsAction, &QAction::triggered, this, &Window::saveAsFile); connect(printAction, &QAction::triggered, this, &Window::popupPrintDialog); + connect(markdownPreviewAction, &QAction::triggered, this, &Window::toggleMarkdownPreview); connect(settingAction, &QAction::triggered, this, &Window::popupSettingsDialog); connect(switchThemeAction, &QAction::triggered, this, &Window::popupThemePanel); qDebug() << "initTitlebar end"; @@ -3553,6 +3563,9 @@ void Window::handleCurrentChanged(const int &index) currentWrapper()->bottomBar()->show(); currentWrapper()->bottomBar()->updateSize(BottomBar::defaultHeight(), false); } + + // 同步 Markdown 预览菜单项状态(可用性 + 勾选状态) + updateMarkdownPreviewActionState(); qDebug() << "handleCurrentChanged end"; } @@ -4515,6 +4528,9 @@ void Window::keyPressEvent(QKeyEvent *e) } else if (key == Utils::getKeyshortcutFromKeymap(m_settings, "window", "print")) { qDebug() << "key press event popup print dialog"; popupPrintDialog(); + } else if (key == Utils::getKeyshortcutFromKeymap(m_settings, "window", "togglemarkdownpreview")) { + qDebug() << "key press event toggle markdown preview"; + toggleMarkdownPreview(); } else { qDebug() << "key press event post event to window widget"; // Post event to window widget if match Alt+0 ~ Alt+9 @@ -4654,6 +4670,29 @@ void Window::setPrintEnabled(bool enabled) } } +void Window::toggleMarkdownPreview() +{ + EditWrapper *wrapper = currentWrapper(); + if (wrapper == nullptr || !MarkdownPreview::isSupported() + || !MarkdownPreview::isMarkdownFile(wrapper->filePath())) { + return; + } + wrapper->setMarkdownPreviewVisible(!wrapper->isMarkdownPreviewVisible()); + updateMarkdownPreviewActionState(); +} + +void Window::updateMarkdownPreviewActionState() +{ + if (m_markdownPreviewAction == nullptr) { + return; + } + EditWrapper *wrapper = currentWrapper(); + const bool isMarkdown = (wrapper != nullptr) && MarkdownPreview::isSupported() + && MarkdownPreview::isMarkdownFile(wrapper->filePath()); + m_markdownPreviewAction->setEnabled(isMarkdown); + m_markdownPreviewAction->setChecked(isMarkdown && wrapper->isMarkdownPreviewVisible()); +} + QStackedWidget *Window::getStackedWgt() { qDebug() << "get stacked wgt"; diff --git a/src/widgets/window.h b/src/widgets/window.h index fc092eed..c0f6756f 100644 --- a/src/widgets/window.h +++ b/src/widgets/window.h @@ -102,6 +102,8 @@ class Window : public DMainWindow void popupThemePanel(); void toggleFullscreen(); + // Markdown 可视化预览开关(仅当前文件为 markdown 时可用) + void toggleMarkdownPreview(); void remberPositionSave(); void remberPositionRestore(); @@ -287,6 +289,11 @@ public Q_SLOTS: bool m_bBatchAddingPendingTabs = false; // 批量添加 pending 标签页期间,阻止 handleCurrentChanged 触发加载 DMenu *m_menu {nullptr}; + // Markdown 预览开关菜单项,状态随当前标签页同步 + QAction *m_markdownPreviewAction {nullptr}; + + // 刷新 Markdown 预览开关的可用性与勾选状态 + void updateMarkdownPreviewActionState(); QStringList m_closeFileHistory; diff --git a/test/at/spi/expected_names.yaml b/test/at/spi/expected_names.yaml index a2c10e88..27329551 100644 --- a/test/at/spi/expected_names.yaml +++ b/test/at/spi/expected_names.yaml @@ -79,6 +79,17 @@ elements: role: "menu item" description: "替换" + - id: "MarkdownPreview" + name: "MarkdownPreview" + role: "menu item" + description: "Markdown 预览开关" + + # Markdown Preview + - id: "MarkdownPreviewView" + name: "MarkdownPreviewView" + role: "text" + description: "Markdown 可视化预览区" + # Titlebar Buttons - id: "AddTab" name: "AddTab" diff --git a/tests/src/editor/ut_markdownpreview.cpp b/tests/src/editor/ut_markdownpreview.cpp new file mode 100644 index 00000000..12b074f0 --- /dev/null +++ b/tests/src/editor/ut_markdownpreview.cpp @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ut_markdownpreview.h" + +UT_MarkdownPreview::UT_MarkdownPreview() +{ + +} + +TEST(UT_MarkdownPreview_isSupported, isSupported) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + EXPECT_TRUE(MarkdownPreview::isSupported()); +#else + EXPECT_FALSE(MarkdownPreview::isSupported()); +#endif +} + +TEST(UT_MarkdownPreview_isMarkdownFile, isMarkdownFile) +{ + EXPECT_TRUE(MarkdownPreview::isMarkdownFile("/tmp/test.md")); + EXPECT_TRUE(MarkdownPreview::isMarkdownFile("/tmp/README.MD")); + EXPECT_TRUE(MarkdownPreview::isMarkdownFile("/tmp/doc.markdown")); + EXPECT_FALSE(MarkdownPreview::isMarkdownFile("/tmp/test.txt")); + EXPECT_FALSE(MarkdownPreview::isMarkdownFile("/tmp/test")); + EXPECT_FALSE(MarkdownPreview::isMarkdownFile(QString())); +} + +TEST(UT_MarkdownPreview_updatePreview, updatePreview) +{ + if (!MarkdownPreview::isSupported()) { + return; + } + MarkdownPreview preview; + preview.updatePreview(QStringLiteral("# Title\n\n**bold** text\n\n- item1\n- item2")); + // 渲染结果应包含标题、强调文本与列表内容 + const QString plain = preview.toPlainText(); + EXPECT_TRUE(plain.contains(QStringLiteral("Title"))); + EXPECT_TRUE(plain.contains(QStringLiteral("bold"))); + EXPECT_TRUE(plain.contains(QStringLiteral("item1"))); + EXPECT_TRUE(plain.contains(QStringLiteral("item2"))); +} + +TEST(UT_MarkdownPreview_setSource, setSourceNoNavigate) +{ + MarkdownPreview preview; + // 预览区只读展示,doSetSource 不产生跳转 + preview.doSetSource(QUrl(QStringLiteral("https://example.com"))); + EXPECT_TRUE(preview.source().isEmpty()); +} diff --git a/tests/src/editor/ut_markdownpreview.h b/tests/src/editor/ut_markdownpreview.h new file mode 100644 index 00000000..e0754b90 --- /dev/null +++ b/tests/src/editor/ut_markdownpreview.h @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef UT_MARKDOWNPREVIEW_H +#define UT_MARKDOWNPREVIEW_H + +#include "gtest/gtest.h" +#include "../../src/editor/markdownpreview.h" + +class UT_MarkdownPreview : public QObject, public ::testing::Test +{ +public: + UT_MarkdownPreview(); +}; + +#endif // UT_MARKDOWNPREVIEW_H