feat: v0.4.0 —— 编辑模式改成文档排版,源代码模式加行号与细线

编辑模式(默认)参照 Typora / Word 的“写字”体验
- 正文改为比例字体,整列限制 760px 居中,隐藏行号栏
- 段落统一行高 150%、段间距统一(applyBlockRhythm)
- 代码片段在文档里仍然保持等宽字体
- 隐藏标记、标题缩放、光标行露出标记等行为保留

源代码模式
- 等宽字体 + 行号栏 + 每行一条细线,通栏不居中
- 标题不再放大,逐行等高,只保留颜色

其它
- 切换模式时字体、边距、标题格式整体重建
- 新增 docs/screenshot-source.png,README 区分两种模式
This commit is contained in:
2026-10-22 17:49:59 +08:00
parent 78c026bd0b
commit c83b4a239c
9 changed files with 144 additions and 25 deletions
+19 -8
View File
@@ -2,15 +2,17 @@
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
当前版本 **0.3.0**。
当前版本 **0.4.0**。
## 界面
| 浅色 | 深色 |
| 浅色 · 编辑模式 | 深色 · 编辑模式 |
| --- | --- |
| ![浅色主题](docs/screenshot-light.png) | ![深色主题](docs/screenshot-dark.png) |
左侧是文件夹侧栏,递归列出目录里的所有 Markdown 文件,点一下就切换。
源代码模式:等宽字体、行号、每行一条细线。
![源代码模式](docs/screenshot-source.png)
## 功能
@@ -38,12 +40,21 @@
### 编辑模式 / 源代码模式
默认是**编辑模式**:Markdown 标记不显示——标题按级别放大加粗、粗体真加粗、
行内代码带底色、链接只留文字,看到的就是排版后的内容。光标移到哪一行,
那一行会露出 `#`、`**` 这些原始标记,可以直接修改。
默认是**编辑模式**,按“写字”的排版来:
点左下角的 **`<src>`**(或 `Ctrl+Shift+M`)切到**源代码模式**:所有标记原样显示,
只做着色,适合核对格式或复制纯文本;按钮会变成 `</src>`。
- 正文用比例字体,整列限制在 760px 内**居中**,像一页文档
- 不显示行号栏
- 标题按级别放大加粗、粗体真加粗、行内代码带底色、链接只留文字
- Markdown 标记默认不显示;光标移到哪一行,那一行才露出 `#`、`**` 这些标记
- 段落统一行高 150%、段间留白
点左下角的 **`<src>`**(或 `Ctrl+Shift+M`)切到**源代码模式**:
- 等宽字体、行号栏、**每行一条细线**,通栏铺满
- 所有 Markdown 标记原样显示,只做着色,不放大标题
- 适合核对格式、对齐、复制纯文本
按钮在源码模式下会变成 `</src>`。
### 文件
+1 -1
View File
@@ -1,6 +1,6 @@
TEMPLATE = app
TARGET = VoiletMark
VERSION = 0.3.1
VERSION = 0.4.0
QT += core gui widgets
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+3
View File
@@ -444,6 +444,7 @@ void MainWindow::showWelcomeDocument()
if (text.startsWith(QLatin1Char('\n')))
text.remove(0, 1);
m_editor->setPlainText(text);
m_editor->applyBlockRhythm();
m_editor->document()->setModified(false);
m_editor->document()->clearUndoRedoStacks();
setCurrentFile(QString());
@@ -455,6 +456,7 @@ void MainWindow::newFile()
if (!maybeSave())
return;
m_editor->clear();
m_editor->applyBlockRhythm();
m_editor->document()->setModified(false);
m_editor->document()->clearUndoRedoStacks();
m_hasBom = false;
@@ -503,6 +505,7 @@ bool MainWindow::openPath(const QString &path)
text.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
m_editor->setPlainText(text);
m_editor->applyBlockRhythm();
m_editor->document()->setModified(false);
m_editor->document()->clearUndoRedoStacks();
setCurrentFile(absolutePath);
+100 -14
View File
@@ -12,6 +12,7 @@
#include <QKeyEvent>
#include <QMimeData>
#include <QPainter>
#include <QPaintEvent>
#include <QRegularExpression>
#include <QTextBlock>
#include <QTextCursor>
@@ -22,6 +23,23 @@
namespace {
constexpr int kLineNumberRightPadding = 14;
constexpr int kMaxTextWidth = 760;
QStringList proseFamilies()
{
return {QStringLiteral("Noto Sans CJK SC"), QStringLiteral("Source Han Sans SC"),
QStringLiteral("WenQuanYi Micro Hei"), QStringLiteral("Microsoft YaHei"),
QStringLiteral("PingFang SC"), QStringLiteral("Segoe UI"),
QStringLiteral("DejaVu Sans")};
}
QStringList monospaceFamilies()
{
return {QStringLiteral("JetBrains Mono"), QStringLiteral("Cascadia Code"),
QStringLiteral("Source Han Mono SC"), QStringLiteral("Noto Sans Mono CJK SC"),
QStringLiteral("Sarasa Mono SC"), QStringLiteral("Consolas"),
QStringLiteral("DejaVu Sans Mono")};
}
const QString &fenceMarker()
{
@@ -88,8 +106,8 @@ MarkdownEditor::MarkdownEditor(QWidget *parent)
connect(ThemeManager::instance(), &ThemeManager::schemeChanged,
this, &MarkdownEditor::applyTheme);
m_lineNumberArea->setVisible(m_sourceMode);
applyEditorFont();
updateLineNumberAreaWidth(0);
updateCurrentLineHighlight();
}
@@ -147,31 +165,45 @@ void MarkdownEditor::setEditorFontPointSize(int points)
void MarkdownEditor::applyEditorFont()
{
QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
font.setFamilies({QStringLiteral("JetBrains Mono"),
QStringLiteral("Cascadia Code"),
QStringLiteral("Source Han Mono SC"),
QStringLiteral("Noto Sans Mono CJK SC"),
QStringLiteral("Sarasa Mono SC"),
QStringLiteral("Consolas"),
QStringLiteral("DejaVu Sans Mono")});
QFont font;
if (m_sourceMode) {
font.setFamilies(monospaceFamilies());
font.setStyleHint(QFont::Monospace);
font.setFixedPitch(true);
} else {
font.setFamilies(proseFamilies());
font.setStyleHint(QFont::SansSerif);
}
font.setPointSize(m_fontPointSize);
font.setStyleHint(QFont::Monospace);
font.setFixedPitch(true);
setFont(font);
document()->setDefaultFont(font);
setTabStopDistance(QFontMetricsF(font).horizontalAdvance(QLatin1Char(' ')) * 4.0);
updateLineNumberAreaWidth(0);
updateViewportMargins();
// 标题字号是根据正文大小算的,缩放后要让高亮层重建格式。
// 标题字号是根据正文大小算的,缩放 / 换字体后要让高亮层重建格式。
if (m_highlighter)
m_highlighter->refreshAppearance();
}
void MarkdownEditor::updateViewportMargins()
{
const int width = contentsRect().width();
int left = 0;
int right = 0;
if (m_sourceMode) {
left = lineNumberAreaWidth();
} else if (width > kMaxTextWidth + 120) {
// 编辑模式:正文限制在一段舒适宽度里并居中,像一页文档。
left = right = (width - kMaxTextWidth) / 2;
}
setViewportMargins(left, 0, right, 0);
}
void MarkdownEditor::updateLineNumberAreaWidth(int)
{
setViewportMargins(lineNumberAreaWidth(), 0, 0, 0);
updateViewportMargins();
}
void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy)
@@ -202,6 +234,29 @@ void MarkdownEditor::applyTheme()
viewport()->update();
}
void MarkdownEditor::applyBlockRhythm()
{
if (!document())
return;
QTextBlockFormat format;
format.setLineHeight(150, QTextBlockFormat::ProportionalHeight);
format.setTopMargin(2);
format.setBottomMargin(6);
const bool wasModified = document()->isModified();
QTextCursor cursor(document());
cursor.beginEditBlock();
for (QTextBlock block = document()->firstBlock(); block.isValid(); block = block.next()) {
QTextCursor blockCursor(block);
blockCursor.setBlockFormat(format);
}
cursor.endEditBlock();
document()->setModified(wasModified);
}
void MarkdownEditor::setSourceMode(bool enabled)
{
if (m_sourceMode == enabled)
@@ -211,7 +266,11 @@ void MarkdownEditor::setSourceMode(bool enabled)
m_activeBlock = textCursor().blockNumber();
m_highlighter->setActiveBlock(m_activeBlock);
m_highlighter->setRendered(!enabled);
m_lineNumberArea->setVisible(enabled);
// 字体、居中和标题格式都跟模式绑定,换模式时整套重建。
applyEditorFont();
updateViewportMargins();
updateCurrentLineHighlight();
viewport()->update();
emit sourceModeChanged(enabled);
@@ -415,11 +474,38 @@ bool MarkdownEditor::enclosingFence(int firstBlockNumber, int lastBlockNumber,
void MarkdownEditor::resizeEvent(QResizeEvent *event)
{
QPlainTextEdit::resizeEvent(event);
updateViewportMargins();
const QRect rect = contentsRect();
m_lineNumberArea->setGeometry(QRect(rect.left(), rect.top(),
lineNumberAreaWidth(), rect.height()));
}
void MarkdownEditor::paintEvent(QPaintEvent *event)
{
QPlainTextEdit::paintEvent(event);
if (!m_sourceMode)
return;
// 源代码模式:每行底下画一条极细的分隔线,像稿纸一样。
QPainter painter(viewport());
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());
}
}
void MarkdownEditor::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Tab) {
+6 -1
View File
@@ -23,9 +23,12 @@ public:
MarkdownHighlighter *highlighter() const { return m_highlighter; }
bool isSourceMode() const { return m_sourceMode; }
/// 源代码模式:关掉语法高亮、行号与当前行高亮,只看文件原始内容。
/// 源代码模式:等宽字体 + 行号 + 每行一条细线,原样显示标记。
void setSourceMode(bool enabled);
/// 给全文套一层统一的段落节奏(行高、段间距),加载文档后调用一次。
void applyBlockRhythm();
/// 把选中的行设为 N 级标题(1–6);整段已经是这一级时改回正文。
void setHeadingLevel(int level);
/// 把选中内容包成围栏代码块;如果已经围起来了则拆掉。
@@ -38,6 +41,7 @@ signals:
protected:
void resizeEvent(QResizeEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
@@ -53,6 +57,7 @@ private slots:
private:
void applyEditorFont();
void updateViewportMargins();
void indentSelection(bool outdent);
bool acceptsLocalFiles(const QMimeData *mime) const;
void selectedBlockRange(int *firstBlock, int *lastBlock) const;
+15 -1
View File
@@ -9,6 +9,14 @@
namespace {
constexpr int StateNormal = 0;
constexpr int StateInFence = 1;
QStringList monospaceFamilies()
{
return {QStringLiteral("JetBrains Mono"), QStringLiteral("Cascadia Code"),
QStringLiteral("Source Han Mono SC"), QStringLiteral("Noto Sans Mono CJK SC"),
QStringLiteral("Sarasa Mono SC"), QStringLiteral("Consolas"),
QStringLiteral("DejaVu Sans Mono")};
}
} // namespace
MarkdownHighlighter::MarkdownHighlighter(QTextDocument *document)
@@ -26,6 +34,8 @@ void MarkdownHighlighter::setRendered(bool rendered)
if (m_rendered == rendered)
return;
m_rendered = rendered;
// 源代码模式不放大标题,格式需要按模式重建。
rebuildFormats();
rehighlight();
}
@@ -58,7 +68,9 @@ void MarkdownHighlighter::rebuildFormats()
m_headingFormats[level] = QTextCharFormat();
m_headingFormats[level].setForeground(c.heading);
m_headingFormats[level].setFontWeight(QFont::Bold);
m_headingFormats[level].setFontPointSize(base * scales[level]);
// 编辑模式按级别放大;源代码模式保持逐行等高,只保留颜色和粗细。
if (m_rendered)
m_headingFormats[level].setFontPointSize(base * scales[level]);
}
m_headingMarkerFormat = QTextCharFormat();
@@ -82,6 +94,8 @@ void MarkdownHighlighter::rebuildFormats()
m_codeFormat.setBackground(c.codeBackground);
m_codeFormat.setFontWeight(QFont::Normal);
m_codeFormat.setFontItalic(false);
m_codeFormat.setFontFamilies(monospaceFamilies());
m_codeFormat.setFontFixedPitch(true);
m_linkFormat = QTextCharFormat();
m_linkFormat.setForeground(c.link);