diff --git a/README.md b/README.md index 3779228..efd121b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。 -当前版本 **0.6.0**。 +当前版本 **0.7.0**。 ## 界面 @@ -24,6 +24,8 @@ - `Ctrl+/` 把选中内容包成围栏代码块,再按一次拆掉 - 默认「编辑模式」隐藏 Markdown 标记、标题按级别放大,光标所在行露出原始标记 - 行号栏、当前行高亮 +- 围栏代码块显示成带圆角边框的面板,右上角有「复制」按钮 + (源代码模式下不显示这些装饰) - `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进 - 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号 - 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显 diff --git a/VoiletMark.pro b/VoiletMark.pro index 0681823..65ec86f 100644 --- a/VoiletMark.pro +++ b/VoiletMark.pro @@ -1,6 +1,6 @@ TEMPLATE = app TARGET = VoiletMark -VERSION = 0.6.0 +VERSION = 0.7.0 QT += core gui widgets printsupport diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png index fb6c8ab..514743f 100644 Binary files a/docs/screenshot-dark.png and b/docs/screenshot-dark.png differ diff --git a/docs/screenshot-light.png b/docs/screenshot-light.png index b13cf1e..80f330c 100644 Binary files a/docs/screenshot-light.png and b/docs/screenshot-light.png differ diff --git a/docs/screenshot-source.png b/docs/screenshot-source.png index 7185454..fde1901 100644 Binary files a/docs/screenshot-source.png and b/docs/screenshot-source.png differ diff --git a/src/MarkdownEditor.cpp b/src/MarkdownEditor.cpp index ae0a09e..d4dd88c 100644 --- a/src/MarkdownEditor.cpp +++ b/src/MarkdownEditor.cpp @@ -4,12 +4,15 @@ #include "ThemeManager.h" #include +#include #include #include #include #include #include +#include #include +#include #include #include #include @@ -17,6 +20,7 @@ #include #include #include +#include #include #include @@ -88,6 +92,7 @@ MarkdownEditor::MarkdownEditor(QWidget *parent) setLineWrapMode(QPlainTextEdit::WidgetWidth); setCursorWidth(2); setAcceptDrops(true); + viewport()->setMouseTracking(true); document()->setDocumentMargin(18); m_lineNumberArea = new LineNumberArea(this); @@ -106,9 +111,16 @@ MarkdownEditor::MarkdownEditor(QWidget *parent) connect(ThemeManager::instance(), &ThemeManager::schemeChanged, this, &MarkdownEditor::applyTheme); + m_fenceTimer = new QTimer(this); + m_fenceTimer->setSingleShot(true); + m_fenceTimer->setInterval(120); + connect(m_fenceTimer, &QTimer::timeout, this, &MarkdownEditor::rebuildCodeFences); + connect(this, &QPlainTextEdit::textChanged, this, [this] { m_fenceTimer->start(); }); + m_lineNumberArea->setVisible(m_sourceMode); applyEditorFont(); updateCurrentLineHighlight(); + rebuildCodeFences(); } int MarkdownEditor::lineNumberAreaWidth() const @@ -483,27 +495,181 @@ void MarkdownEditor::resizeEvent(QResizeEvent *event) void MarkdownEditor::paintEvent(QPaintEvent *event) { QPlainTextEdit::paintEvent(event); - if (!m_sourceMode) + + QPainter painter(viewport()); + + if (m_sourceMode) { + // 源代码模式:每行底下画一条极细的分隔线,像稿纸一样。 + QColor lineColor = ThemeManager::instance()->colors().border; + lineColor.setAlpha(120); + painter.setPen(lineColor); + + QTextBlock block = firstVisibleBlock(); + int top = qRound(blockBoundingGeometry(block).translated(contentOffset()).top()); + int bottom = top + qRound(blockBoundingRect(block).height()); + const int width = viewport()->width(); + + while (block.isValid() && top <= event->rect().bottom()) { + if (block.isVisible() && bottom >= event->rect().top()) + painter.drawLine(0, bottom - 1, width, bottom - 1); + block = block.next(); + top = bottom; + bottom = top + qRound(blockBoundingRect(block).height()); + } + return; + } + + // 编辑模式:代码块套一个圆角框,右上角给个复制按钮。 + const ThemeColors &colors = ThemeManager::instance()->colors(); + const QRectF visible(event->rect()); + + for (int index = 0; index < m_codeFences.size(); ++index) { + const QRectF rect = codeFenceRect(index); + if (!rect.isValid() || !rect.intersects(visible)) + continue; + + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(colors.border, 1)); + painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 6, 6); + + const QRectF button = copyButtonRect(index); + if (!button.isValid()) + continue; + + QFont small = font(); + small.setPointSizeF(qMax(7.0, font().pointSizeF() - 2.0)); + painter.setFont(small); + + const bool hovered = (m_hoveredFence == index); + const bool copied = (m_copiedFence == index); + painter.setPen(QPen(hovered ? colors.accent : colors.border, 1)); + painter.setBrush(colors.editorBase); + painter.drawRoundedRect(button, 4, 4); + painter.setPen(copied ? colors.accent : colors.placeholder); + painter.drawText(button, Qt::AlignCenter, copied ? tr("已复制") : tr("复制")); + } +} + +QRectF MarkdownEditor::codeFenceRect(int index) const +{ + if (index < 0 || index >= m_codeFences.size()) + return QRectF(); + + const CodeFence &fence = m_codeFences.at(index); + const QTextBlock first = document()->findBlockByNumber(fence.open); + const int lastNumber = fence.close >= 0 ? fence.close : document()->blockCount() - 1; + const QTextBlock last = document()->findBlockByNumber(lastNumber); + if (!first.isValid() || !last.isValid()) + return QRectF(); + + const QRectF firstRect = blockBoundingGeometry(first).translated(contentOffset()); + const QRectF lastRect = blockBoundingGeometry(last).translated(contentOffset()); + const qreal top = firstRect.top() - 3; + const qreal bottom = lastRect.bottom() + 3; + return QRectF(3, top, viewport()->width() - 6, bottom - top); +} + +QRectF MarkdownEditor::copyButtonRect(int index) const +{ + const QRectF rect = codeFenceRect(index); + if (!rect.isValid()) + return QRectF(); + + QFont small = font(); + small.setPointSizeF(qMax(7.0, font().pointSizeF() - 2.0)); + const QFontMetricsF metrics(small); + const QString label = (m_copiedFence == index) ? tr("已复制") : tr("复制"); + const qreal width = metrics.horizontalAdvance(label) + 16; + const qreal height = metrics.height() + 6; + return QRectF(rect.right() - width - 8, rect.top() + 6, width, height); +} + +QString MarkdownEditor::codeFenceText(int index) const +{ + if (index < 0 || index >= m_codeFences.size()) + return QString(); + + const CodeFence &fence = m_codeFences.at(index); + const int end = fence.close >= 0 ? fence.close : document()->blockCount(); + QStringList lines; + for (int number = fence.open + 1; number < end; ++number) + lines << document()->findBlockByNumber(number).text(); + return lines.join(QLatin1Char('\n')); +} + +void MarkdownEditor::rebuildCodeFences() +{ + m_codeFences.clear(); + if (!document()) return; - // 源代码模式:每行底下画一条极细的分隔线,像稿纸一样。 - QPainter painter(viewport()); - QColor lineColor = ThemeManager::instance()->colors().border; - lineColor.setAlpha(120); - painter.setPen(lineColor); + static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)")); + bool inFence = false; + int open = -1; - QTextBlock block = firstVisibleBlock(); - int top = qRound(blockBoundingGeometry(block).translated(contentOffset()).top()); - int bottom = top + qRound(blockBoundingRect(block).height()); - const int width = viewport()->width(); - - while (block.isValid() && top <= event->rect().bottom()) { - if (block.isVisible() && bottom >= event->rect().top()) - painter.drawLine(0, bottom - 1, width, bottom - 1); - block = block.next(); - top = bottom; - bottom = top + qRound(blockBoundingRect(block).height()); + for (QTextBlock block = document()->firstBlock(); block.isValid(); block = block.next()) { + if (!fencePattern.match(block.text()).hasMatch()) + continue; + if (!inFence) { + inFence = true; + open = block.blockNumber(); + } else { + CodeFence fence; + fence.open = open; + fence.close = block.blockNumber(); + m_codeFences.append(fence); + inFence = false; + open = -1; + } } + if (inFence && open >= 0) { + CodeFence fence; + fence.open = open; + m_codeFences.append(fence); + } + + viewport()->update(); +} + +void MarkdownEditor::mouseMoveEvent(QMouseEvent *event) +{ + QPlainTextEdit::mouseMoveEvent(event); + if (m_sourceMode) + return; + + int hovered = -1; + for (int index = 0; index < m_codeFences.size(); ++index) { + if (copyButtonRect(index).contains(event->pos())) { + hovered = index; + break; + } + } + + if (hovered != m_hoveredFence) { + m_hoveredFence = hovered; + viewport()->setCursor(hovered >= 0 ? Qt::PointingHandCursor : Qt::IBeamCursor); + viewport()->update(); + } +} + +void MarkdownEditor::mousePressEvent(QMouseEvent *event) +{ + if (!m_sourceMode && event->button() == Qt::LeftButton) { + for (int index = 0; index < m_codeFences.size(); ++index) { + if (!copyButtonRect(index).contains(event->pos())) + continue; + + QGuiApplication::clipboard()->setText(codeFenceText(index)); + m_copiedFence = index; + QTimer::singleShot(1200, this, [this] { + m_copiedFence = -1; + viewport()->update(); + }); + viewport()->update(); + return; + } + } + QPlainTextEdit::mousePressEvent(event); } void MarkdownEditor::keyPressEvent(QKeyEvent *event) diff --git a/src/MarkdownEditor.h b/src/MarkdownEditor.h index c4cfd93..aa2952c 100644 --- a/src/MarkdownEditor.h +++ b/src/MarkdownEditor.h @@ -1,9 +1,11 @@ #pragma once #include +#include class MarkdownHighlighter; class QTextBlock; +class QTimer; /// 编辑器本体:等宽字体、行号栏、当前行高亮、Tab 缩进、 /// Ctrl+滚轮缩放,以及把拖入的文件转交给主窗口打开。 @@ -42,6 +44,8 @@ signals: protected: void resizeEvent(QResizeEvent *event) override; void paintEvent(QPaintEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; void keyPressEvent(QKeyEvent *event) override; void wheelEvent(QWheelEvent *event) override; void dragEnterEvent(QDragEnterEvent *event) override; @@ -54,20 +58,34 @@ private slots: void updateCurrentLineHighlight(); void updateActiveBlock(); void applyTheme(); + void rebuildCodeFences(); private: void applyEditorFont(); void updateViewportMargins(); void indentSelection(bool outdent); + QRectF codeFenceRect(int index) const; + QRectF copyButtonRect(int index) const; + QString codeFenceText(int index) const; bool acceptsLocalFiles(const QMimeData *mime) const; void selectedBlockRange(int *firstBlock, int *lastBlock) const; void selectBlocks(int firstBlock, int lastBlock); bool enclosingFence(int firstBlock, int lastBlock, QTextBlock *opener, QTextBlock *closer) const; + struct CodeFence { + int open = -1; // 开围栏所在行 + int close = -1; // 闭围栏所在行,未闭合时为 -1 + }; + QWidget *m_lineNumberArea = nullptr; MarkdownHighlighter *m_highlighter = nullptr; int m_fontPointSize = 12; bool m_sourceMode = false; int m_activeBlock = -1; + + QVector m_codeFences; + QTimer *m_fenceTimer = nullptr; + int m_hoveredFence = -1; + int m_copiedFence = -1; }; diff --git a/src/MarkdownHighlighter.cpp b/src/MarkdownHighlighter.cpp index deae045..d24f511 100644 --- a/src/MarkdownHighlighter.cpp +++ b/src/MarkdownHighlighter.cpp @@ -97,6 +97,12 @@ void MarkdownHighlighter::rebuildFormats() m_codeFormat.setFontFamilies(monospaceFamilies()); m_codeFormat.setFontFixedPitch(true); + // 围栏代码块:等宽、普通文字色,不带底色——外面由编辑器画框。 + m_fenceFormat = QTextCharFormat(); + m_fenceFormat.setFontFamilies(monospaceFamilies()); + m_fenceFormat.setFontFixedPitch(true); + m_fenceFormat.setForeground(c.editorText); + m_linkFormat = QTextCharFormat(); m_linkFormat.setForeground(c.link); m_linkFormat.setFontUnderline(true); @@ -198,8 +204,13 @@ void MarkdownHighlighter::highlightBlock(const QString &text) if (inFence) { if (!fenceLine) setCurrentBlockState(StateInFence); - if (rendered && fenceLine && !active) { - apply(0, text.length(), hiddenFormat(c.editorBase)); + if (rendered) { + if (fenceLine && !active) { + apply(0, text.length(), hiddenFormat(c.editorBase)); + } else { + m_blockBase = m_fenceFormat; + apply(0, text.length(), m_fenceFormat); + } } else { m_blockBase = m_codeFormat; apply(0, text.length(), m_codeFormat); @@ -208,10 +219,14 @@ void MarkdownHighlighter::highlightBlock(const QString &text) } if (fenceLine) { setCurrentBlockState(StateInFence); - if (rendered && !active) - apply(0, text.length(), hiddenFormat(c.editorBase)); - else + if (rendered) { + if (!active) + apply(0, text.length(), hiddenFormat(c.editorBase)); + else + apply(0, text.length(), m_fenceFormat); + } else { apply(0, text.length(), m_codeFormat); + } return; } diff --git a/src/MarkdownHighlighter.h b/src/MarkdownHighlighter.h index 3ffa107..4585147 100644 --- a/src/MarkdownHighlighter.h +++ b/src/MarkdownHighlighter.h @@ -55,6 +55,7 @@ private: QTextCharFormat m_boldItalicFormat; QTextCharFormat m_emphasisFormat; QTextCharFormat m_codeFormat; + QTextCharFormat m_fenceFormat; QTextCharFormat m_linkFormat; QTextCharFormat m_urlFormat; QTextCharFormat m_quoteFormat; diff --git a/translations/voiletmark_en.qm b/translations/voiletmark_en.qm index 5632f97..c848676 100644 Binary files a/translations/voiletmark_en.qm and b/translations/voiletmark_en.qm differ diff --git a/translations/voiletmark_en.ts b/translations/voiletmark_en.ts index 35e7dca..36a42bb 100644 --- a/translations/voiletmark_en.ts +++ b/translations/voiletmark_en.ts @@ -3,372 +3,387 @@ MainWindow - - + + 新建 New - - + + 打开… Open… - - + + 保存 Save - - + + 另存为… Save As… - - + + 导出 PDF… Export PDF… - - + + 退出 Quit - - + + 撤销 Undo - - + + 重做 Redo - - + + 剪切 Cut - - + + 复制 Copy - - + + 粘贴 Paste - - + + 全选 Select All - - + + 自动换行 Word Wrap - - + + 放大 Zoom In - - + + 缩小 Zoom Out - - + + 实际大小 Actual Size - - + + 打开文件夹… Open Folder… - - - - + + + + 侧栏 Sidebar - - + + 源代码模式 Source Mode - - + + 代码块 Code Block - - + + 标题 %1 Heading %1 - + 文件夹 Folder - - + + 未打开文件夹 No folder open - - + + 文件 File - - + + 最近打开 Recent Files - - + + 编辑 Edit - - + + 视图 View - - + + 主题 Theme - - + + 6:00–18:00 使用浅色,其余时间使用深色 Light from 6:00 to 18:00, dark the rest of the day - - + + 语言 Language - - + + 格式 Format - - + + 标题 Heading - - + + 帮助 Help - - - + + + 关于 VoiletMark About VoiletMark - - + + 显示 / 隐藏文件夹侧栏(Ctrl+B) Show / hide the folder sidebar (Ctrl+B) - - + + 源代码模式:关闭语法高亮与行号(Ctrl+Shift+M) Source mode: turn off highlighting and line numbers (Ctrl+Shift+M) - - + + 未命名 Untitled - + 打开 Markdown 文件 Open Markdown File - + Markdown 文件 (*.md *.markdown *.mdown *.mkd);;文本文件 (*.txt);;所有文件 (*) Markdown files (*.md *.markdown *.mdown *.mkd);;Text files (*.txt);;All files (*) - + 无法打开文件 Cannot Open File - + 读取「%1」失败:%2 Could not read "%1": %2 - + 另存为 Save As - + Markdown 文件 (*.md);;文本文件 (*.txt);;所有文件 (*) Markdown files (*.md);;Text files (*.txt);;All files (*) - + 导出 PDF Export PDF - + PDF 文件 (*.pdf) PDF files (*.pdf) - + 导出失败 Export Failed - + 没能写出「%1」,换个位置再试一次。 Could not write "%1". Try another location. - + 已导出 %1 Exported to %1 - - - + + + 无法保存文件 Cannot Save File - + 写入「%1」失败:%2 Could not write "%1": %2 - + 写入「%1」时发生错误。 An error occurred while writing "%1". - + 提交「%1」失败:%2 Could not commit "%1": %2 - + 已保存 %1 Saved to %1 - + VoiletMark VoiletMark - + 「%1」有未保存的修改,是否保存? "%1" has unsaved changes. Save them? - + %1[*] — VoiletMark %1[*] — VoiletMark - + 行 %1,列 %2 Line %1, Col %2 - + 已选 %1 字 %1 selected - + %1 字 %1 chars - + <h3>VoiletMark %1</h3><p>一个轻量的跨平台 Markdown 编辑器。</p><p>基于 Qt %2 与 C++17 构建。</p> <h3>VoiletMark %1</h3><p>A lightweight cross-platform Markdown editor.</p><p>Built with Qt %2 and C++17.</p> - + (暂无记录) (none) - + 清空列表 Clear List - + 打开文件夹 Open Folder - + 文件 → 打开文件夹… File → Open Folder… - + (没有 Markdown 文件) (no Markdown files) + + MarkdownEditor + + + + 已复制 + Copied + + + + + 复制 + Copy + + QObject