Skip to content

Commit d96aea4

Browse files
feat(markdown): copy button and clipboard routing
- Copy button was missing from code blocks in the preview panel; users had no way to grab code without manual selection. - Clipboard actions (cut/copy/paste/select-all) always routed to the Scintilla editor even when focus was in another widget (e.g. the preview or a dialog), causing silent no-ops or wrong-target edits. - <span style> tags in highlighted code broke QTextBrowser's block background detection used by the copy button heuristic; <font color> is the subset QTextDocument actually parses for inline color. - Code font family was not applied to <pre>/<code> blocks, only to body text, so monospace font preference was ignored in previews.
1 parent 1c925a0 commit d96aea4

5 files changed

Lines changed: 169 additions & 12 deletions

File tree

src/MarkdownPreviewWidget.cpp

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,16 @@
66
#include <QImage>
77
#include <QScrollBar>
88
#include <QTextDocument>
9+
#include <QTextBlock>
10+
#include <QTextBlockFormat>
11+
#include <QAbstractTextDocumentLayout>
12+
#include <QTextFrame>
913
#include <QtConcurrent>
1014
#include <QKeyEvent>
15+
#include <QMouseEvent>
16+
#include <QToolButton>
17+
#include <QGuiApplication>
18+
#include <QClipboard>
1119

1220
MarkdownPreviewWidget::MarkdownPreviewWidget(NotepadNextApplication *app, QWidget *parent)
1321
: PreviewContentWidget(parent)
@@ -20,8 +28,21 @@ MarkdownPreviewWidget::MarkdownPreviewWidget(NotepadNextApplication *app, QWidge
2028
m_browser->setOpenExternalLinks(true);
2129
m_browser->setFrameShape(QFrame::NoFrame);
2230
m_browser->setReadOnly(true);
31+
m_browser->viewport()->setMouseTracking(true);
32+
m_browser->viewport()->installEventFilter(this);
2333
layout->addWidget(m_browser);
2434

35+
m_copyBtn = new QToolButton(m_browser->viewport());
36+
m_copyBtn->setText(QStringLiteral("Copy"));
37+
m_copyBtn->setAutoRaise(true);
38+
m_copyBtn->setCursor(Qt::PointingHandCursor);
39+
m_copyBtn->setStyleSheet(QStringLiteral(
40+
"QToolButton { background: palette(mid); border: none; padding: 2px 6px;"
41+
" border-radius: 3px; font-size: 11px; }"
42+
"QToolButton:hover { background: palette(dark); }"));
43+
m_copyBtn->hide();
44+
connect(m_copyBtn, &QToolButton::clicked, this, &MarkdownPreviewWidget::copyCurrentCodeBlock);
45+
2546
m_relayoutTimer.setSingleShot(true);
2647
m_relayoutTimer.setInterval(100);
2748
connect(&m_relayoutTimer, &QTimer::timeout, this, [this]() {
@@ -122,6 +143,8 @@ void MarkdownPreviewWidget::renderAsync(const QString &text)
122143
void MarkdownPreviewWidget::applyHtml(const QString &html)
123144
{
124145
m_cachedHtml = html;
146+
m_copyBtn->hide();
147+
m_hoveredCodeBlock = QTextBlock();
125148
int scrollPos = m_browser->verticalScrollBar()->value();
126149
m_browser->setHtml(html);
127150
m_browser->verticalScrollBar()->setValue(scrollPos);
@@ -132,3 +155,86 @@ void MarkdownPreviewWidget::onFontChanged()
132155
if (m_cachedHtml.isEmpty()) return;
133156
renderAsync(m_lastSourceText);
134157
}
158+
159+
bool MarkdownPreviewWidget::eventFilter(QObject *watched, QEvent *event)
160+
{
161+
if (watched == m_browser->viewport()) {
162+
if (event->type() == QEvent::MouseMove) {
163+
auto *me = static_cast<QMouseEvent *>(event);
164+
updateCopyButtonPosition(me->pos());
165+
} else if (event->type() == QEvent::Leave) {
166+
if (!m_copyBtn->underMouse()) {
167+
m_copyBtn->hide();
168+
m_hoveredCodeBlock = QTextBlock();
169+
}
170+
}
171+
}
172+
return PreviewContentWidget::eventFilter(watched, event);
173+
}
174+
175+
void MarkdownPreviewWidget::updateCopyButtonPosition(const QPoint &mousePos)
176+
{
177+
QTextCursor cursor = m_browser->cursorForPosition(mousePos);
178+
QTextBlock block = cursor.block();
179+
180+
if (!block.isValid() || !isCodeBlock(block)) {
181+
m_copyBtn->hide();
182+
m_hoveredCodeBlock = QTextBlock();
183+
return;
184+
}
185+
186+
// Find the first block of this code group
187+
QTextBlock first = block;
188+
while (first.previous().isValid() && isCodeBlock(first.previous()))
189+
first = first.previous();
190+
191+
if (m_hoveredCodeBlock == first && m_copyBtn->isVisible())
192+
return;
193+
194+
m_hoveredCodeBlock = first;
195+
196+
QRectF firstRect = m_browser->document()->documentLayout()->blockBoundingRect(first);
197+
int scrollY = m_browser->verticalScrollBar()->value();
198+
199+
QSize btnSize = m_copyBtn->sizeHint();
200+
int x = m_browser->viewport()->width() - btnSize.width() - 8;
201+
int y = static_cast<int>(firstRect.top()) - scrollY + 4;
202+
203+
if (y < 0) y = 4;
204+
205+
m_copyBtn->move(x, y);
206+
m_copyBtn->show();
207+
m_copyBtn->raise();
208+
}
209+
210+
void MarkdownPreviewWidget::copyCurrentCodeBlock()
211+
{
212+
if (!m_hoveredCodeBlock.isValid()) return;
213+
QString text = extractCodeBlockText(m_hoveredCodeBlock);
214+
if (!text.isEmpty())
215+
QGuiApplication::clipboard()->setText(text);
216+
}
217+
218+
QString MarkdownPreviewWidget::extractCodeBlockText(const QTextBlock &block) const
219+
{
220+
QString result;
221+
QTextBlock b = block;
222+
while (b.isValid() && isCodeBlock(b)) {
223+
if (!result.isEmpty()) result += QLatin1Char('\n');
224+
result += b.text();
225+
b = b.next();
226+
}
227+
return result;
228+
}
229+
230+
bool MarkdownPreviewWidget::isCodeBlock(const QTextBlock &block) const
231+
{
232+
if (!block.isValid()) return false;
233+
QTextBlockFormat fmt = block.blockFormat();
234+
// QTextBrowser renders <pre> blocks with a non-default background from our CSS
235+
QBrush bg = fmt.background();
236+
if (bg.style() == Qt::NoBrush) return false;
237+
// Check that the background differs from the document's default (body) background
238+
QColor docBg = m_browser->document()->rootFrame()->frameFormat().background().color();
239+
return bg.color() != docBg && bg.color().isValid();
240+
}

src/MarkdownPreviewWidget.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
#include "MarkdownRenderer.h"
66

77
#include <QTextBrowser>
8+
#include <QTextBlock>
89
#include <QFutureWatcher>
910
#include <QHash>
1011
#include <QUrl>
1112
#include <QTimer>
1213
#include <atomic>
1314

1415
class NotepadNextApplication;
16+
class QToolButton;
1517

1618
class MarkdownPreviewWidget : public PreviewContentWidget
1719
{
@@ -28,14 +30,23 @@ class MarkdownPreviewWidget : public PreviewContentWidget
2830

2931
void scrollToLine(int line);
3032

33+
protected:
34+
bool eventFilter(QObject *watched, QEvent *event) override;
35+
3136
private:
3237
void renderAsync(const QString &text);
3338
void applyHtml(const QString &html);
3439
MarkdownRenderRequest buildRequest(const QString &text);
3540
void onFontChanged();
41+
void updateCopyButtonPosition(const QPoint &mousePos);
42+
void copyCurrentCodeBlock();
43+
QString extractCodeBlockText(const QTextBlock &block) const;
44+
bool isCodeBlock(const QTextBlock &block) const;
3645

3746
NotepadNextApplication *m_app;
3847
QTextBrowser *m_browser;
48+
QToolButton *m_copyBtn = nullptr;
49+
QTextBlock m_hoveredCodeBlock;
3950
QString m_title;
4051
QString m_basePath;
4152
QPalette m_palette;

src/MarkdownRenderer.cpp

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,10 @@ QString MarkdownRenderer::highlightCodeBlock(const QByteArray &code, const QStri
266266
for (int i = 0; i < code.size(); ++i) {
267267
int s = static_cast<unsigned char>(styles[i]);
268268
if (s != prevStyle) {
269-
if (prevStyle >= 0) html += QStringLiteral("</span>");
269+
if (prevStyle >= 0) html += QStringLiteral("</font>");
270270
SemanticStyle sem = classifyStyle(s);
271271
const QString &color = colorForSemantic(sem, isDark);
272-
html += QStringLiteral("<span style=\"color:") + color + QStringLiteral("\">");
272+
html += QStringLiteral("<font color=\"") + color + QStringLiteral("\">");
273273
prevStyle = s;
274274
}
275275
char ch = code[i];
@@ -281,7 +281,7 @@ QString MarkdownRenderer::highlightCodeBlock(const QByteArray &code, const QStri
281281
default: html += QLatin1Char(ch); break;
282282
}
283283
}
284-
if (prevStyle >= 0) html += QStringLiteral("</span>");
284+
if (prevStyle >= 0) html += QStringLiteral("</font>");
285285
html += QStringLiteral("</code></pre>");
286286
return html;
287287
}
@@ -305,21 +305,29 @@ QString MarkdownRenderer::buildStyleBlock(const QPalette &palette, bool isDark,
305305
if (fontSize > 0)
306306
fontCss += QStringLiteral("font-size:%1pt;").arg(fontSize);
307307

308+
QString codeFontCss;
309+
if (!fontFamily.isEmpty())
310+
codeFontCss += QStringLiteral("font-family:'%1',monospace;").arg(fontFamily);
311+
else
312+
codeFontCss += QStringLiteral("font-family:monospace;");
313+
if (fontSize > 0)
314+
codeFontCss += QStringLiteral("font-size:%1pt;").arg(fontSize);
315+
308316
return QStringLiteral(
309317
"<style>"
310318
"body{background:%1;color:%2;%6padding:16px;line-height:1.6;}"
311319
"a{color:%3;}"
312-
"code{background:%4;padding:2px 4px;border-radius:3px;font-family:monospace;}"
313-
"pre{background:%4;padding:12px;border-radius:4px;overflow-x:auto;}"
314-
"pre code{background:none;padding:0;}"
320+
"code{background:%4;padding:2px 4px;border-radius:3px;%7line-height:normal;}"
321+
"pre{background:%4;padding:0.8em 1em;border-radius:4px;overflow-x:auto;line-height:normal;}"
322+
"pre code{background:none;padding:0;line-height:normal;}"
315323
"blockquote{border-left:3px solid %5;padding-left:12px;margin-left:0;}"
316324
"table{border-collapse:collapse;}"
317325
"th,td{border:1px solid %5;padding:6px 12px;}"
318326
"th{background:%4;}"
319327
"hr{border:none;border-top:1px solid %5;}"
320328
"img{max-width:100%%;}"
321329
"</style>"
322-
).arg(bg, fg, link, altBase, mid, fontCss);
330+
).arg(bg, fg, link, altBase, mid, fontCss, codeFontCss);
323331
}
324332

325333
namespace {

src/dialogs/MainWindow.cpp

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,12 @@
123123
#include "CrashHandler.h"
124124
#include "ProfileScope.h"
125125

126+
#include <QApplication>
126127
#include <QEvent>
127128
#include <QActionEvent>
128129
#include <QFont>
129130
#include <QMenu>
131+
#include <QMetaObject>
130132
#include <QPointer>
131133
#include "DockWidget.h"
132134

@@ -197,6 +199,24 @@ class ActionAddedFilter : public QObject
197199
}
198200
};
199201

202+
bool isEditorFocused()
203+
{
204+
QWidget *w = QApplication::focusWidget();
205+
while (w) {
206+
if (qobject_cast<ScintillaNext *>(w))
207+
return true;
208+
w = w->parentWidget();
209+
}
210+
return false;
211+
}
212+
213+
bool forwardClipboardToFocusWidget(const char *slot)
214+
{
215+
QWidget *w = QApplication::focusWidget();
216+
if (!w) return false;
217+
return QMetaObject::invokeMethod(w, slot);
218+
}
219+
200220
} // namespace
201221

202222

@@ -489,11 +509,23 @@ MainWindow::MainWindow(NotepadNextApplication *app) :
489509

490510
connect(ui->actionUndo, &QAction::triggered, this, [=]() { currentEditor()->undo(); });
491511
connect(ui->actionRedo, &QAction::triggered, this, [=]() { currentEditor()->redo(); });
492-
connect(ui->actionCut, &QAction::triggered, this, [=]() { currentEditor()->cutAllowLine(); });
493-
connect(ui->actionCopy, &QAction::triggered, this, [=]() { currentEditor()->copyAllowLine(); });
512+
connect(ui->actionCut, &QAction::triggered, this, [=]() {
513+
if (!isEditorFocused() && forwardClipboardToFocusWidget("cut")) return;
514+
currentEditor()->cutAllowLine();
515+
});
516+
connect(ui->actionCopy, &QAction::triggered, this, [=]() {
517+
if (!isEditorFocused() && forwardClipboardToFocusWidget("copy")) return;
518+
currentEditor()->copyAllowLine();
519+
});
494520
connect(ui->actionDelete, &QAction::triggered, this, [=]() { currentEditor()->clear(); });
495-
connect(ui->actionPaste, &QAction::triggered, this, [=]() { currentEditor()->paste(); });
496-
connect(ui->actionSelectAll, &QAction::triggered, this, [=]() { currentEditor()->selectAll(); });
521+
connect(ui->actionPaste, &QAction::triggered, this, [=]() {
522+
if (!isEditorFocused() && forwardClipboardToFocusWidget("paste")) return;
523+
currentEditor()->paste();
524+
});
525+
connect(ui->actionSelectAll, &QAction::triggered, this, [=]() {
526+
if (!isEditorFocused() && forwardClipboardToFocusWidget("selectAll")) return;
527+
currentEditor()->selectAll();
528+
});
497529
connect(ui->actionSelectNext, &QAction::triggered, this, [=]() {
498530
ScintillaNext *editor = currentEditor();
499531

tests/test_markdown_renderer.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ private slots:
144144
MarkdownRenderRequest req = makeRequest("```cpp\nint main() { return 0; }\n```");
145145
req.resolvedLexers.insert("cpp", "cpp");
146146
auto result = MarkdownRenderer::render(req);
147-
QVERIFY(result.html.contains("<span style=\"color:"));
147+
QVERIFY(result.html.contains("<font color=\""));
148148
}
149149

150150
void highlightedCodeBlock_unknownLanguage_noSpans()

0 commit comments

Comments
 (0)