feat: add markdown support for user notes (#6490)
Reviewed-by: pajlada <rasmus.karlsson@pajlada.com> Reviewed-by: Nerixyz <nerixdev@outlook.de>
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||