feat: add markdown support for user notes (#6490)

Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
Reviewed-by: Nerixyz <nerixdev@outlook.de>
This commit is contained in:
Jacob Alexander Thompson
2025-12-28 02:39:27 -08:00
committed by GitHub
parent a6d7918f07
commit e084ae6ef1
22 changed files with 640 additions and 12 deletions
+1
View File
@@ -20,6 +20,7 @@
- Minor: Added broadcaster-only `/poll`, `/cancelpoll`, and `/endpoll` commands. (#6583, #6605)
- Minor: Added broadcaster-only `/prediction`, `/cancelprediction`, `/lockprediction`, and `/completeprediction` commands. (#6583, #6612, #6632)
- Minor: Added support for BetterTTV Pro subscriber badges. (#6625)
- Minor: Added Markdown support to user notes. (#6490)
- Bugfix: Moderation checks now include the lead moderator badge. (#6642)
- Bugfix: Fixed lead moderator badges not being filtered by the `Channel` badge setting. (#6665)
- Bugfix: Expose the "Extra extension IDs" setting on non-Windows systems too. (#6509)
+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.935 4.44A1.5 1.5 0 0 1 7.996 4h4.383C15.017 4 17 6.182 17 8.625a4.63 4.63 0 0 1-.865 2.682c1.077.827 1.866 2.12 1.866 3.813C18 18.232 15.3 20 13.12 20H8a1.5 1.5 0 0 1-1.5-1.5l-.004-13c0-.397.158-.779.44-1.06ZM9.5 10.25h2.88c.903 0 1.62-.76 1.62-1.625S13.281 7 12.38 7H9.498l.002 3.25Zm0 3V17h3.62c.874 0 1.88-.754 1.88-1.88 0-1.13-.974-1.87-1.88-1.87H9.5Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 484 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.935 4.44A1.5 1.5 0 0 1 7.996 4h4.383C15.017 4 17 6.182 17 8.625a4.63 4.63 0 0 1-.865 2.682c1.077.827 1.866 2.12 1.866 3.813C18 18.232 15.3 20 13.12 20H8a1.5 1.5 0 0 1-1.5-1.5l-.004-13c0-.397.158-.779.44-1.06ZM9.5 10.25h2.88c.903 0 1.62-.76 1.62-1.625S13.281 7 12.38 7H9.498l.002 3.25Zm0 3V17h3.62c.874 0 1.88-.754 1.88-1.88 0-1.13-.974-1.87-1.88-1.87H9.5Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 487 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3.25 17.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494l-.102.006H6.75a.75.75 0 0 1-.102-1.493L6.75 18h14.5-14.5Zm-3.5-7a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494L21.25 13H6.75a.75.75 0 0 1-.102-1.493l.102-.007h14.5-14.5Zm-3.5-7A1.25 1.25 0 1 1 3.25 7a1.25 1.25 0 0 1 0-2.499Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494l-.102.006H6.75a.75.75 0 0 1-.102-1.493L6.75 5h14.5-14.5Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 567 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3.25 17.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494l-.102.006H6.75a.75.75 0 0 1-.102-1.493L6.75 18h14.5-14.5Zm-3.5-7a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494L21.25 13H6.75a.75.75 0 0 1-.102-1.493l.102-.007h14.5-14.5Zm-3.5-7A1.25 1.25 0 1 1 3.25 7a1.25 1.25 0 0 1 0-2.499Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494l-.102.006H6.75a.75.75 0 0 1-.102-1.493L6.75 5h14.5-14.5Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 570 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.505 5.004a1.5 1.5 0 0 0 0 3h7a1.5 1.5 0 0 0 0-3h-7Zm11.49-.908A2.25 2.25 0 0 0 17.75 2H6.25l-.154.005A2.25 2.25 0 0 0 4 4.25v15.5l.005.154A2.25 2.25 0 0 0 6.25 22h11.5l.154-.005A2.25 2.25 0 0 0 20 19.75V4.25l-.005-.154ZM6.25 3.5h11.5l.102.007a.75.75 0 0 1 .648.743v15.5l-.007.102a.75.75 0 0 1-.743.648H6.25l-.102-.007a.75.75 0 0 1-.648-.743V4.25l.007-.102A.75.75 0 0 1 6.25 3.5Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 507 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.505 5.004a1.5 1.5 0 0 0 0 3h7a1.5 1.5 0 0 0 0-3h-7Zm11.49-.908A2.25 2.25 0 0 0 17.75 2H6.25l-.154.005A2.25 2.25 0 0 0 4 4.25v15.5l.005.154A2.25 2.25 0 0 0 6.25 22h11.5l.154-.005A2.25 2.25 0 0 0 20 19.75V4.25l-.005-.154ZM6.25 3.5h11.5l.102.007a.75.75 0 0 1 .648.743v15.5l-.007.102a.75.75 0 0 1-.743.648H6.25l-.102-.007a.75.75 0 0 1-.648-.743V4.25l.007-.102A.75.75 0 0 1 6.25 3.5Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 510 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.75 4h8.504a.75.75 0 0 1 .102 1.493l-.102.006h-3.197L10.037 18.5h4.213a.75.75 0 0 1 .742.648l.007.102a.75.75 0 0 1-.648.743L14.25 20h-9.5a.747.747 0 0 1-.746-.75c0-.38.28-.694.645-.743l.101-.007h3.685l.021-.065L13.45 5.499h-3.7a.75.75 0 0 1-.742-.648L9 4.75a.75.75 0 0 1 .648-.743L9.751 4h8.503-8.503Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 429 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.75 4h8.504a.75.75 0 0 1 .102 1.493l-.102.006h-3.197L10.037 18.5h4.213a.75.75 0 0 1 .742.648l.007.102a.75.75 0 0 1-.648.743L14.25 20h-9.5a.747.747 0 0 1-.746-.75c0-.38.28-.694.645-.743l.101-.007h3.685l.021-.065L13.45 5.499h-3.7a.75.75 0 0 1-.742-.648L9 4.75a.75.75 0 0 1 .648-.743L9.751 4h8.503-8.503Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 432 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 496 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 499 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 542 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 545 B

+2
View File
@@ -616,6 +616,8 @@ set(SOURCE_FILES
widgets/FramelessEmbedWindow.hpp
widgets/Label.cpp
widgets/Label.hpp
widgets/MarkdownLabel.cpp
widgets/MarkdownLabel.hpp
widgets/Notebook.cpp
widgets/Notebook.hpp
widgets/OverlayWindow.cpp
+2 -1
View File
@@ -178,6 +178,7 @@ void Label::updateSize()
auto yPadding =
this->currentPadding_.top() + this->currentPadding_.bottom();
auto height = metrics.height() + yPadding;
if (this->shouldElide_)
{
@@ -214,7 +215,7 @@ bool Label::updateElidedText(const QFontMetricsF &fontMetrics, qreal width)
QRectF Label::textRect() const
{
return this->rect().toRectF().marginsRemoved(this->currentPadding_);
return QRectF(this->rect()).marginsRemoved(this->currentPadding_);
}
} // namespace chatterino
+1 -2
View File
@@ -43,8 +43,7 @@ protected:
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
private:
void updateSize();
virtual void updateSize();
QRectF textRect() const;
/// Returns the current font style's font metric based on the current scale.
+188
View File
@@ -0,0 +1,188 @@
#include "widgets/MarkdownLabel.hpp"
#include "Application.hpp"
#include "singletons/Theme.hpp"
#include <QAbstractTextDocumentLayout>
#include <QDesktopServices>
#include <QMouseEvent>
#include <QPainter>
#include <QTextDocument>
#include <QUrl>
namespace chatterino {
MarkdownLabel::MarkdownLabel(BaseWidget *parent, QString text, FontStyle style)
: Label(parent, std::move(text), style)
, markdownDocument(new QTextDocument(this))
{
if (!this->text_.isEmpty())
{
this->markdownDocument->setMarkdown(this->text_);
}
this->setMouseTracking(true);
}
void MarkdownLabel::setText(const QString &text)
{
assert(this->markdownDocument != nullptr);
if (this->text_ != text)
{
this->text_ = text;
this->markdownDocument->setMarkdown(text);
this->updateSize();
this->update();
}
}
void MarkdownLabel::paintEvent(QPaintEvent * /*event*/)
{
assert(this->markdownDocument != nullptr);
QPainter painter(this);
auto font =
getApp()->getFonts()->getFont(this->getFontStyle(), this->scale());
painter.setFont(font);
// draw text
QRectF textRect = this->textRect();
if (!this->text_.isEmpty())
{
QColor textColor =
this->theme ? this->theme->messages.textColors.regular : Qt::black;
this->markdownDocument->setTextWidth(textRect.width());
this->markdownDocument->setDefaultFont(font);
this->markdownDocument->setMarkdown(this->text_);
QPalette docPalette = this->palette();
docPalette.setColor(QPalette::Text, textColor);
docPalette.setColor(QPalette::WindowText, textColor);
painter.setPen(textColor);
painter.save();
painter.translate(textRect.topLeft());
// create a rendering context using our text color and document palette
QAbstractTextDocumentLayout::PaintContext paintContext;
paintContext.palette = docPalette;
paintContext.clip = QRectF(0, 0, textRect.width(), textRect.height());
this->markdownDocument->documentLayout()->draw(&painter, paintContext);
painter.restore();
}
else
{
// Fall back to the base Label rendering if no markdown document
Label::paintEvent(nullptr);
return;
}
}
void MarkdownLabel::mousePressEvent(QMouseEvent *event)
{
assert(this->markdownDocument != nullptr);
if (event->button() == Qt::LeftButton)
{
QRectF textRect = this->textRect();
QPointF pos = event->pos() - textRect.topLeft();
QString anchor =
this->markdownDocument->documentLayout()->anchorAt(pos);
if (!anchor.isEmpty())
{
QUrl url(anchor);
// Validate the URL and add scheme if missing
if (!url.isValid())
{
return;
}
// If the URL doesn't have a scheme, assume it's http
if (url.scheme().isEmpty())
{
url.setScheme("http");
}
// Only open URLs with safe schemes
QString scheme = url.scheme().toLower();
if (scheme == "http" || scheme == "https" || scheme == "ftp" ||
scheme == "file" || scheme == "mailto")
{
QDesktopServices::openUrl(url);
}
return;
}
}
Label::mousePressEvent(event);
}
void MarkdownLabel::mouseMoveEvent(QMouseEvent *event)
{
assert(this->markdownDocument != nullptr);
QRectF textRect = this->textRect();
QPointF pos = event->pos() - textRect.topLeft();
QString anchor = this->markdownDocument->documentLayout()->anchorAt(pos);
if (!anchor.isEmpty())
{
this->setCursor(Qt::PointingHandCursor);
}
else
{
this->setCursor(Qt::ArrowCursor);
}
Label::mouseMoveEvent(event);
}
void MarkdownLabel::updateSize()
{
assert(this->markdownDocument != nullptr);
this->currentPadding_ = this->basePadding_.toMarginsF() * this->scale();
if (!this->text_.isEmpty())
{
this->markdownDocument->setDefaultFont(
getApp()->getFonts()->getFont(this->getFontStyle(), this->scale()));
this->markdownDocument->setMarkdown(this->text_);
// Use word wrap width if enabled, otherwise use a reasonable default
qreal testWidth = this->wordWrap_
? 400.0 * this->scale()
: this->markdownDocument->idealWidth();
this->markdownDocument->setTextWidth(testWidth);
auto yPadding =
this->currentPadding_.top() + this->currentPadding_.bottom();
auto height = this->markdownDocument->size().height() + yPadding;
auto width = qMin(this->markdownDocument->idealWidth(), testWidth) +
this->currentPadding_.left() +
this->currentPadding_.right();
this->sizeHint_ = QSizeF(width, height).toSize();
this->minimumSizeHint_ = this->sizeHint_;
this->updateGeometry();
}
else
{
// Fall back to base Label size calculation
Label::updateSize();
}
}
} // namespace chatterino
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "widgets/Label.hpp"
class QTextDocument;
namespace chatterino {
/// @brief A Label that supports rendering markdown text
///
/// MarkdownLabel inherits from Label and adds markdown rendering capabilities.
/// It automatically handles markdown document creation and rendering.
class MarkdownLabel : public Label
{
public:
explicit MarkdownLabel(BaseWidget *parent, QString text,
FontStyle style = FontStyle::UiMedium);
void setText(const QString &text);
protected:
void paintEvent(QPaintEvent * /*event*/) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
private:
void updateSize() override;
QTextDocument *markdownDocument;
};
} // namespace chatterino
+382 -2
View File
@@ -2,8 +2,14 @@
#include "singletons/Theme.hpp"
#include "util/LayoutCreator.hpp"
#include "widgets/buttons/SvgButton.hpp"
#include "widgets/MarkdownLabel.hpp"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QRegularExpression>
#include <QSplitter>
#include <QTextBlock>
#include <QTextEdit>
namespace chatterino {
@@ -17,12 +23,281 @@ EditUserNotesDialog::EditUserNotesDialog(QWidget *parent)
},
parent)
{
this->setScaleIndependentSize(500, 350);
this->setScaleIndependentSize(700, 450);
auto layout = LayoutCreator<QWidget>(this->getLayoutContainer())
.setLayoutType<QVBoxLayout>();
auto edit = layout.emplace<QTextEdit>().assign(&this->textEdit_);
auto headerLayout = layout.emplace<QHBoxLayout>();
auto *headingButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/heading-darkMode.svg",
.light = ":/buttons/heading-lightMode.svg",
})
.getElement();
QObject::connect(headingButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
const auto line = cursor.block().text();
const auto pos = EditUserNotesDialog::currentWordPosition(cursor);
const auto selectedText = cursor.selectedText();
if (!selectedText.isEmpty() &&
EditUserNotesDialog::isHeading(line, pos))
{
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 1);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 4);
cursor.removeSelectedText();
// restore selection
cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else if (cursor.hasSelection())
{
cursor.insertText("### " + selectedText);
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else
{
cursor.insertText("### ");
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
headingButton->setToolTip("Insert a heading");
auto *boldButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/bold-darkMode.svg",
.light = ":/buttons/bold-lightMode.svg",
})
.getElement();
QObject::connect(boldButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
const auto line = cursor.block().text();
const auto pos = cursor.columnNumber();
auto selectedText = cursor.selectedText();
if (!selectedText.isEmpty() && EditUserNotesDialog::isBold(line, pos))
{
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor,
1); // un-select
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 2);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length() + 4));
cursor.insertText(selectedText);
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else if (cursor.hasSelection())
{
auto appended = 0;
QChar newLine(QChar::ParagraphSeparator);
if (selectedText.back() == newLine)
{
selectedText.chop(1);
cursor.insertText("**" + selectedText + "**" + newLine);
appended = 3;
}
else
{
cursor.insertText("**" + selectedText + "**");
appended = 2;
}
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor,
appended);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else
{
cursor.insertText("****");
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 2);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
boldButton->setToolTip("Make selected text bold");
auto *italicButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/italic-darkMode.svg",
.light = ":/buttons/italic-lightMode.svg",
})
.getElement();
QObject::connect(italicButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
const auto line = cursor.block().text();
const auto pos = cursor.columnNumber();
auto selectedText = cursor.selectedText();
if (!selectedText.isEmpty() && EditUserNotesDialog::isItalic(line, pos))
{
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor,
1); // un-select
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 1);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length() + 2));
cursor.insertText(selectedText);
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else if (cursor.hasSelection())
{
auto appended = 0;
QChar newLine(QChar::ParagraphSeparator);
if (selectedText.back() == newLine)
{
selectedText.chop(1);
cursor.insertText("*" + selectedText + "*" + newLine);
appended = 2;
}
else
{
cursor.insertText("*" + selectedText + "*");
appended = 1;
}
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor,
appended);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else
{
cursor.insertText("**");
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 1);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
italicButton->setToolTip("Make selected text italic");
auto *quoteButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/quote-darkMode.svg",
.light = ":/buttons/quote-lightMode.svg",
})
.getElement();
QObject::connect(quoteButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
if (cursor.hasSelection())
{
auto selectedText = cursor.selectedText();
cursor.insertText("> " + selectedText);
}
else
{
cursor.insertText("> ");
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
quoteButton->setToolTip("Insert a blockquote");
auto *linkButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/link-darkMode.svg",
.light = ":/buttons/link-lightMode.svg",
})
.getElement();
QObject::connect(linkButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
if (cursor.hasSelection())
{
auto appended = 0;
auto selectedText = cursor.selectedText();
QChar newLine(QChar::ParagraphSeparator);
if (selectedText.back() == newLine)
{
selectedText.chop(1);
cursor.insertText("[" + selectedText + "](url)" + newLine);
appended = 2;
}
else
{
cursor.insertText("[" + selectedText + "](url)");
appended = 1;
}
// select "url" for easy replacement
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor,
appended);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 3);
}
else
{
cursor.insertText("[](url)");
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 1);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 3);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
linkButton->setToolTip("Insert a hyperlink");
auto *listButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/bullet-list-darkMode.svg",
.light = ":/buttons/bullet-list-lightMode.svg",
})
.getElement();
QObject::connect(listButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
if (cursor.hasSelection())
{
cursor.select(QTextCursor::LineUnderCursor);
const auto selectedText = cursor.selectedText();
cursor.insertText("- " + selectedText);
cursor.movePosition(QTextCursor::StartOfLine,
QTextCursor::KeepAnchor, 0);
}
else
{
const auto pos = cursor.columnNumber();
cursor.movePosition(QTextCursor::StartOfLine,
QTextCursor::MoveAnchor, 0);
cursor.insertText("- ");
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor,
pos);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
listButton->setToolTip("Insert a bullet list item");
headerLayout->addSpacing(175); // group markdown toolbar buttons
auto previewCheckBox =
headerLayout.emplace<QCheckBox>("Show Markdown Preview")
.assign(&this->previewCheckBox_);
auto splitter =
layout.emplace<QSplitter>(Qt::Horizontal).assign(&this->splitter_);
auto edit = splitter.emplace<QTextEdit>().assign(&this->textEdit_);
auto preview = splitter.emplace<MarkdownLabel>(this, QString())
.assign(&this->previewLabel_);
preview->setWordWrap(true);
preview->setPadding(QMargins(10, 10, 10, 10));
this->splitter_->setSizes({350, 350});
this->previewLabel_->setVisible(false);
layout
.emplace<QDialogButtonBox>(QDialogButtonBox::Ok |
@@ -36,6 +311,31 @@ EditUserNotesDialog::EditUserNotesDialog(QWidget *parent)
this->close();
});
// Connect preview toggle
QObject::connect(this->previewCheckBox_, &QCheckBox::toggled, this,
[this](bool checked) {
this->previewLabel_->setVisible(checked);
if (checked)
{
this->updatePreview();
this->splitter_->setSizes({350, 350});
this->textEdit_->setFocus();
}
else
{
this->splitter_->setSizes({700, 0});
this->textEdit_->setFocus();
}
});
// Connect text changes to preview update
QObject::connect(this->textEdit_, &QTextEdit::textChanged, this, [this] {
if (this->previewCheckBox_->isChecked())
{
this->updatePreview();
}
});
this->themeChangedEvent();
}
@@ -63,6 +363,8 @@ void EditUserNotesDialog::themeChangedEvent()
return;
}
assert(this->previewLabel_ != nullptr);
auto palette = this->palette();
palette.setColor(QPalette::Window,
@@ -76,6 +378,84 @@ void EditUserNotesDialog::themeChangedEvent()
{
this->textEdit_->setPalette(palette);
}
this->previewLabel_->setPalette(palette);
}
void EditUserNotesDialog::updatePreview()
{
assert(this->textEdit_ != nullptr);
assert(this->previewLabel_ != nullptr);
QString text = this->textEdit_->toPlainText();
if (text.isEmpty())
{
this->previewLabel_->setText(
"*Preview will appear here when you type markdown text...*");
}
else
{
this->previewLabel_->setText(text);
}
}
int EditUserNotesDialog::currentWordPosition(const QTextCursor &cursor)
{
QTextCursor temp = cursor;
temp.movePosition(QTextCursor::StartOfWord, QTextCursor::MoveAnchor);
return temp.columnNumber();
}
bool EditUserNotesDialog::isBold(const QString &line, const int pos)
{
static QRegularExpression pattern(R"((\*{2,})(.*?)(\1))");
QRegularExpressionMatchIterator iter = pattern.globalMatch(line);
while (iter.hasNext())
{
QRegularExpressionMatch match = iter.next();
const auto startIndex = match.capturedStart(2);
const auto endIndex = match.capturedEnd(2);
if (pos == startIndex || pos == endIndex)
{
return true;
}
}
return false;
}
bool EditUserNotesDialog::isItalic(const QString &line, const int pos)
{
static QRegularExpression pattern(R"(\*(.*?)\*)");
QRegularExpressionMatchIterator iter = pattern.globalMatch(line);
while (iter.hasNext())
{
QRegularExpressionMatch match = iter.next();
const auto startIndex = match.capturedStart(1);
const auto endIndex = match.capturedEnd(1);
if (pos == startIndex || pos == endIndex)
{
return true;
}
}
return false;
}
bool EditUserNotesDialog::isHeading(const QString &line, const int pos)
{
static QRegularExpression pattern(R"(###\s)");
QRegularExpressionMatch match = pattern.match(line, pos - 4);
return match.hasMatch() && pos == match.capturedEnd();
}
} // namespace chatterino
@@ -3,10 +3,15 @@
#include "pajlada/signals/signal.hpp"
#include "widgets/BasePopup.hpp"
class QCheckBox;
class QSplitter;
class QTextCursor;
class QTextEdit;
namespace chatterino {
class MarkdownLabel;
class EditUserNotesDialog : public BasePopup
{
Q_OBJECT
@@ -24,7 +29,16 @@ protected:
void themeChangedEvent() override;
private:
void updatePreview();
static int currentWordPosition(const QTextCursor &cursor);
static bool isBold(const QString &line, const int pos);
static bool isItalic(const QString &line, const int pos);
static bool isHeading(const QString &line, const int pos);
QTextEdit *textEdit_{};
QCheckBox *previewCheckBox_{};
QSplitter *splitter_{};
MarkdownLabel *previewLabel_{};
};
} // namespace chatterino
+4 -6
View File
@@ -36,6 +36,7 @@
#include "widgets/helper/LiveIndicator.hpp"
#include "widgets/helper/ScalingSpacerItem.hpp"
#include "widgets/Label.hpp"
#include "widgets/MarkdownLabel.hpp"
#include "widgets/Notebook.hpp"
#include "widgets/Scrollbar.hpp"
#include "widgets/splits/Split.hpp"
@@ -496,7 +497,8 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split)
});
}
auto notesPreview = layout.emplace<Label>().assign(&ui_.notesPreview);
auto notesPreview = layout.emplace<MarkdownLabel>(this, QString())
.assign(&this->ui_.notesPreview);
notesPreview->setVisible(false);
notesPreview->setShouldElide(true);
@@ -1188,11 +1190,7 @@ void UserInfoPopup::updateNotes()
return;
}
static QRegularExpression spaceRegex{"\\s+"};
auto previewText = "Notes: " + userData->notes.replace(spaceRegex, " ");
this->ui_.notesPreview->setText(previewText);
this->ui_.notesPreview->setText(userData->notes);
this->ui_.notesPreview->setVisible(true);
}
+2 -1
View File
@@ -13,6 +13,7 @@ namespace chatterino {
class Channel;
using ChannelPtr = std::shared_ptr<Channel>;
class Label;
class MarkdownLabel;
class EditUserNotesDialog;
class ChannelView;
class Split;
@@ -90,7 +91,7 @@ private:
QCheckBox *block = nullptr;
QCheckBox *ignoreHighlights = nullptr;
Label *notesPreview = nullptr;
MarkdownLabel *notesPreview = nullptr;
LabelButton *notesAdd = nullptr;
Label *noMessagesLabel = nullptr;