Skip to content

Commit 9bff889

Browse files
committed
#672 note-edit: add heading depth markdown operations
1 parent 16aff34 commit 9bff889

3 files changed

Lines changed: 179 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## 26.4.21
44

5+
- Added a new **Markdown operations** submenu to the **note text edit** context
6+
menu with actions to increase or decrease the heading depth of selected ATX
7+
and setext Markdown headings (for [#672](https://github.com/pbek/QOwnNotes/issues/672))
58
- The **note link dialog** note list now also shows the `Modified` column even
69
when note sub-folder support is disabled (for [#1679](https://github.com/pbek/QOwnNotes/issues/1679))
710
- Fixed auto-detected links inside italic or bold Markdown text so trailing

src/widgets/qownnotesmarkdowntextedit.cpp

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
#include <QVariant>
4646
#include <QVector>
4747
#include <QWidgetAction>
48+
#include <QtGlobal>
4849
#include <algorithm>
4950

5051
#include "entities/notefolder.h"
@@ -71,6 +72,158 @@ constexpr int kMarkdownLspDiagnosticProperty = QTextFormat::UserProperty + 1;
7172
constexpr int kFoldIndicatorPadding = 4;
7273
QHash<QString, QSet<QString>> s_foldedHeadingStateByNoteReference;
7374

75+
struct SetextHeadingUnderline {
76+
int level = 0;
77+
int leadingSpaces = 0;
78+
int markerCount = 0;
79+
80+
bool isValid() const { return level > 0; }
81+
};
82+
83+
static int markdownHeadingIndent(const QString &line) {
84+
int i = 0;
85+
while (i < line.size() && i < 3 && line.at(i) == QLatin1Char(' ')) {
86+
++i;
87+
}
88+
89+
return i;
90+
}
91+
92+
static int atxHeadingLevel(const QString &line, int *headingMarkerStart = nullptr,
93+
int *headingMarkerEnd = nullptr) {
94+
int i = markdownHeadingIndent(line);
95+
const int markerStart = i;
96+
while (i < line.size() && line.at(i) == QLatin1Char('#')) {
97+
++i;
98+
}
99+
100+
const int headingLevel = i - markerStart;
101+
if (headingLevel <= 0 || headingLevel > 6) {
102+
return 0;
103+
}
104+
105+
if (i < line.size() && line.at(i) != QLatin1Char(' ') && line.at(i) != QLatin1Char('\t')) {
106+
return 0;
107+
}
108+
109+
if (headingMarkerStart) {
110+
*headingMarkerStart = markerStart;
111+
}
112+
113+
if (headingMarkerEnd) {
114+
*headingMarkerEnd = i;
115+
}
116+
117+
return headingLevel;
118+
}
119+
120+
static SetextHeadingUnderline parseSetextHeadingUnderline(const QString &line) {
121+
SetextHeadingUnderline underline;
122+
123+
int i = markdownHeadingIndent(line);
124+
underline.leadingSpaces = i;
125+
126+
if (i >= line.size()) {
127+
return underline;
128+
}
129+
130+
const QChar marker = line.at(i);
131+
if (marker != QLatin1Char('=') && marker != QLatin1Char('-')) {
132+
return underline;
133+
}
134+
135+
const int markerStart = i;
136+
while (i < line.size() && line.at(i) == marker) {
137+
++i;
138+
}
139+
140+
underline.markerCount = i - markerStart;
141+
142+
while (i < line.size() && (line.at(i) == QLatin1Char(' ') || line.at(i) == QLatin1Char('\t'))) {
143+
++i;
144+
}
145+
146+
if (i != line.size()) {
147+
return {};
148+
}
149+
150+
underline.level = (marker == QLatin1Char('=')) ? 1 : 2;
151+
return underline;
152+
}
153+
154+
static int boundedHeadingLevel(const int headingLevel, const int levelDelta) {
155+
#if __cplusplus >= 201703L
156+
return std::clamp(headingLevel + levelDelta, 1, 6);
157+
#else
158+
return qBound(1, headingLevel + levelDelta, 6);
159+
#endif
160+
}
161+
162+
QString changeHeadingDepth(const QString &text, const int levelDelta) {
163+
QString normalizedText = text;
164+
normalizedText.replace(QChar(0x2029), QLatin1Char('\n'));
165+
166+
QStringList lines = normalizedText.split(QLatin1Char('\n'), Qt::KeepEmptyParts);
167+
QStringList updatedLines;
168+
updatedLines.reserve(lines.size());
169+
170+
for (int lineIndex = 0; lineIndex < lines.size(); ++lineIndex) {
171+
const QString &line = lines.at(lineIndex);
172+
173+
int headingMarkerStart = 0;
174+
int headingMarkerEnd = 0;
175+
const int atxLevel = atxHeadingLevel(line, &headingMarkerStart, &headingMarkerEnd);
176+
if (atxLevel > 0) {
177+
const int newHeadingLevel = boundedHeadingLevel(atxLevel, levelDelta);
178+
if (newHeadingLevel == atxLevel) {
179+
updatedLines.append(line);
180+
continue;
181+
}
182+
183+
updatedLines.append(line.left(headingMarkerStart) +
184+
QString(newHeadingLevel, QLatin1Char('#')) +
185+
line.mid(headingMarkerEnd));
186+
continue;
187+
}
188+
189+
if (lineIndex + 1 < lines.size()) {
190+
const SetextHeadingUnderline underline =
191+
parseSetextHeadingUnderline(lines.at(lineIndex + 1));
192+
const int titleIndent = markdownHeadingIndent(line);
193+
const QString titleText = line.mid(titleIndent).trimmed();
194+
195+
if (underline.isValid() && !titleText.isEmpty()) {
196+
const int newHeadingLevel = boundedHeadingLevel(underline.level, levelDelta);
197+
if (newHeadingLevel <= 2) {
198+
updatedLines.append(line);
199+
200+
if (newHeadingLevel == underline.level) {
201+
updatedLines.append(lines.at(lineIndex + 1));
202+
} else {
203+
const int underlineWidth = std::max(
204+
underline.markerCount, std::max(static_cast<int>(titleText.size()), 3));
205+
const QChar marker =
206+
(newHeadingLevel == 1) ? QLatin1Char('=') : QLatin1Char('-');
207+
updatedLines.append(QString(underline.leadingSpaces, QLatin1Char(' ')) +
208+
QString(underlineWidth, marker));
209+
}
210+
} else {
211+
updatedLines.append(line.left(titleIndent) +
212+
QString(newHeadingLevel, QLatin1Char('#')) +
213+
QStringLiteral(" ") + titleText);
214+
}
215+
216+
++lineIndex;
217+
continue;
218+
}
219+
}
220+
221+
updatedLines.append(line);
222+
}
223+
224+
return updatedLines.join(QLatin1Char('\n'));
225+
}
226+
74227
struct InnerSelectionCandidate {
75228
int innerStart = -1;
76229
int innerEnd = -1;
@@ -1346,6 +1499,15 @@ bool QOwnNotesMarkdownTextEdit::replaceFullLineSelection(const QString &text) {
13461499
return true;
13471500
}
13481501

1502+
bool QOwnNotesMarkdownTextEdit::changeHeadingDepthOfSelection(const int levelDelta) {
1503+
QTextCursor cursor = fullLineSelectionCursor();
1504+
if (!cursor.hasSelection()) {
1505+
return false;
1506+
}
1507+
1508+
return replaceFullLineSelection(changeHeadingDepth(cursor.selectedText(), levelDelta));
1509+
}
1510+
13491511
QMargins QOwnNotesMarkdownTextEdit::viewportMargins() {
13501512
return QMarkdownTextEdit::viewportMargins();
13511513
}
@@ -2352,6 +2514,19 @@ void QOwnNotesMarkdownTextEdit::onContextMenu(QPoint pos) {
23522514
Utils::ListUtils::orderCheckboxes(fullLineSelectionCursor().selectedText()));
23532515
});
23542516

2517+
QMenu *markdownOperationsMenu = menu->addMenu(tr("Markdown operations"));
2518+
markdownOperationsMenu->setEnabled(isAllowNoteEditing);
2519+
2520+
QAction *increaseHeadingDepthAction =
2521+
markdownOperationsMenu->addAction(tr("Increase heading depth"));
2522+
connect(increaseHeadingDepthAction, &QAction::triggered, this,
2523+
[this]() { changeHeadingDepthOfSelection(1); });
2524+
2525+
QAction *decreaseHeadingDepthAction =
2526+
markdownOperationsMenu->addAction(tr("Decrease heading depth"));
2527+
connect(decreaseHeadingDepthAction, &QAction::triggered, this,
2528+
[this]() { changeHeadingDepthOfSelection(-1); });
2529+
23552530
menu->addAction(MainWindow::instance()->searchTextOnWebAction());
23562531
menu->addAction(MainWindow::instance()->findNoteAction());
23572532
}

src/widgets/qownnotesmarkdowntextedit.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ class QOwnNotesMarkdownTextEdit : public QMarkdownTextEdit {
173173
void onContextMenu(QPoint pos);
174174
QTextCursor fullLineSelectionCursor() const;
175175
bool replaceFullLineSelection(const QString &text);
176+
bool changeHeadingDepthOfSelection(int levelDelta);
176177

177178
void overrideFontSizeStyle(int fontSize);
178179

0 commit comments

Comments
 (0)