Add automatic markdown formatting
This commit is contained in:
@@ -84,7 +84,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.bold
|
||||
onClicked: {
|
||||
root.focusedDocumentHandler.bold = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Bold);
|
||||
root.clicked()
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.italic
|
||||
onClicked: {
|
||||
root.focusedDocumentHandler.italic = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Italic);
|
||||
root.clicked()
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.underline
|
||||
onClicked: {
|
||||
root.focusedDocumentHandler.underline = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Underline);
|
||||
root.clicked();
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.strikethrough
|
||||
onClicked: {
|
||||
root.focusedDocumentHandler.strikethrough = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Strikethrough);
|
||||
root.clicked()
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.bold
|
||||
onTriggered: {
|
||||
root.focusedDocumentHandler.bold = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Bold);
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.italic
|
||||
onTriggered: {
|
||||
root.focusedDocumentHandler.italic = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Italic);
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.underline
|
||||
onTriggered: {
|
||||
root.focusedDocumentHandler.underline = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Underline);
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.strikethrough
|
||||
onTriggered: {
|
||||
root.focusedDocumentHandler.strikethrough = checked;
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.Strikethrough);
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.currentListStyle === 1
|
||||
onClicked: {
|
||||
root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 1 ? 0 : 1);
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.UnorderedList);
|
||||
root.clicked();
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ QQC2.ToolBar {
|
||||
checkable: true
|
||||
checked: root.focusedDocumentHandler.currentListStyle === 4
|
||||
onClicked: {
|
||||
root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 4 ? 0 : 4);
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.OrderedList);
|
||||
root.clicked();
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ QQC2.ToolBar {
|
||||
icon.name: "format-list-unordered"
|
||||
text: i18nc("@action:button", "Unordered List")
|
||||
onTriggered: {
|
||||
root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 1 ? 0 : 1);
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.UnorderedList);
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
@@ -311,7 +311,7 @@ QQC2.ToolBar {
|
||||
icon.name: "format-list-ordered"
|
||||
text: i18nc("@action:button", "Ordered List")
|
||||
onTriggered: {
|
||||
root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 4 ? 0 : 4);
|
||||
root.focusedDocumentHandler.setFormat(LibNeoChat.RichFormat.OrderedList);
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,18 @@ QQC2.Popup {
|
||||
leftPadding: lineRow.visible ? lineRow.width + lineRow.anchors.leftMargin + Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
enabled: root.chatContentModel.focusType !== LibNeoChat.MessageComponentType.Code || styleDelegate.index === LibNeoChat.TextStyle.Paragraph || styleDelegate.index === LibNeoChat.TextStyle.Quote
|
||||
enabled: root.chatContentModel.focusType !== LibNeoChat.MessageComponentType.Code || styleDelegate.index === LibNeoChat.RichFormat.Paragraph || styleDelegate.index === LibNeoChat.RichFormat.Quote
|
||||
readOnly: true
|
||||
selectByMouse: false
|
||||
|
||||
onPressed: (event) => {
|
||||
if (styleDelegate.index === LibNeoChat.TextStyle.Paragraph ||
|
||||
styleDelegate.index === LibNeoChat.TextStyle.Code ||
|
||||
styleDelegate.index === LibNeoChat.TextStyle.Quote
|
||||
if (styleDelegate.index === LibNeoChat.RichFormat.Paragraph ||
|
||||
styleDelegate.index === LibNeoChat.RichFormat.Code ||
|
||||
styleDelegate.index === LibNeoChat.RichFormat.Quote
|
||||
) {
|
||||
root.chatContentModel.insertStyleAtCursor(styleDelegate.index);
|
||||
} else {
|
||||
root.focusedDocumentHandler.style = styleDelegate.index;
|
||||
root.focusedDocumentHandler.setFormat(styleDelegate.index);
|
||||
}
|
||||
root.close();
|
||||
}
|
||||
@@ -61,7 +61,7 @@ QQC2.Popup {
|
||||
leftMargin: Kirigami.Units.smallSpacing
|
||||
}
|
||||
|
||||
visible: styleDelegate.index === LibNeoChat.TextStyle.Code
|
||||
visible: styleDelegate.index === LibNeoChat.RichFormat.Code
|
||||
|
||||
QQC2.Label {
|
||||
horizontalAlignment: Text.AlignRight
|
||||
@@ -82,7 +82,7 @@ QQC2.Popup {
|
||||
|
||||
background: Rectangle {
|
||||
color: Kirigami.Theme.backgroundColor
|
||||
Kirigami.Theme.colorSet: styleDelegate.index === LibNeoChat.TextStyle.Quote ? Kirigami.Theme.Window : Kirigami.Theme.View
|
||||
Kirigami.Theme.colorSet: styleDelegate.index === LibNeoChat.RichFormat.Quote ? Kirigami.Theme.Window : Kirigami.Theme.View
|
||||
Kirigami.Theme.inherit: false
|
||||
radius: Kirigami.Units.cornerRadius
|
||||
border {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#include <QTextCursor>
|
||||
#include <QTextDocument>
|
||||
|
||||
#include "enums/textstyle.h"
|
||||
#include "enums/richformat.h"
|
||||
|
||||
StyleDelegateHelper::StyleDelegateHelper(QObject *parent)
|
||||
: QObject(parent)
|
||||
@@ -59,8 +59,8 @@ void StyleDelegateHelper::formatDocument()
|
||||
cursor.beginEditBlock();
|
||||
cursor.select(QTextCursor::Document);
|
||||
cursor.removeSelectedText();
|
||||
const auto style = static_cast<TextStyle::Style>(m_textItem->property("index").toInt());
|
||||
const auto string = TextStyle::styleString(style);
|
||||
const auto style = static_cast<RichFormat::Format>(m_textItem->property("index").toInt());
|
||||
const auto string = RichFormat::styleString(style);
|
||||
|
||||
const int headingLevel = style <= 6 ? style : 0;
|
||||
// Apparently, 5 is maximum for FontSizeAdjustment; otherwise level=1 and
|
||||
@@ -74,9 +74,9 @@ void StyleDelegateHelper::formatDocument()
|
||||
QTextCharFormat chrfmt;
|
||||
chrfmt.setFontWeight(headingLevel > 0 ? QFont::Bold : QFont::Normal);
|
||||
chrfmt.setProperty(QTextFormat::FontSizeAdjustment, sizeAdjustment / 2);
|
||||
if (style == TextStyle::Code) {
|
||||
if (style == RichFormat::Code) {
|
||||
chrfmt.setFontFamilies({u"monospace"_s});
|
||||
} else if (style == TextStyle::Quote) {
|
||||
} else if (style == RichFormat::Quote) {
|
||||
chrfmt.setFontItalic(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ target_sources(LibNeoChat PRIVATE
|
||||
accountmanager.cpp
|
||||
chatbarcache.cpp
|
||||
chatdocumenthandler.cpp
|
||||
chatmarkdownhelper.cpp
|
||||
clipboard.cpp
|
||||
delegatesizehelper.cpp
|
||||
emojitones.cpp
|
||||
@@ -31,9 +32,9 @@ target_sources(LibNeoChat PRIVATE
|
||||
enums/messagetype.h
|
||||
enums/powerlevel.cpp
|
||||
enums/pushrule.h
|
||||
enums/richformat.cpp
|
||||
enums/roomsortparameter.cpp
|
||||
enums/roomsortorder.h
|
||||
enums/textstyle.h
|
||||
enums/timelinemarkreadcondition.h
|
||||
events/imagepackevent.cpp
|
||||
events/pollevent.cpp
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include <Sonnet/BackgroundChecker>
|
||||
#include <Sonnet/Settings>
|
||||
#include <qfont.h>
|
||||
#include <qlogging.h>
|
||||
#include <qnamespace.h>
|
||||
#include <qtextcursor.h>
|
||||
@@ -28,8 +29,8 @@
|
||||
|
||||
#include "chatbartype.h"
|
||||
#include "chatdocumenthandler_logging.h"
|
||||
#include "chatmarkdownhelper.h"
|
||||
#include "eventhandler.h"
|
||||
#include "textstyle.h"
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
@@ -130,6 +131,8 @@ ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
|
||||
, m_highlighter(new SyntaxHighlighter(this))
|
||||
, m_completionModel(new CompletionModel(this))
|
||||
{
|
||||
m_markdownHelper = new ChatMarkdownHelper(this);
|
||||
connect(this, &ChatDocumentHandler::formatChanged, m_markdownHelper, &ChatMarkdownHelper::handleExternalFormatChange);
|
||||
}
|
||||
|
||||
int ChatDocumentHandler::completionStartIndex() const
|
||||
@@ -162,6 +165,22 @@ void ChatDocumentHandler::setType(ChatBarType::Type type)
|
||||
Q_EMIT typeChanged();
|
||||
}
|
||||
|
||||
NeoChatRoom *ChatDocumentHandler::room() const
|
||||
{
|
||||
return m_room;
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setRoom(NeoChatRoom *room)
|
||||
{
|
||||
if (m_room == room) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_room = room;
|
||||
m_completionModel->setRoom(m_room);
|
||||
Q_EMIT roomChanged();
|
||||
}
|
||||
|
||||
QQuickItem *ChatDocumentHandler::textItem() const
|
||||
{
|
||||
return m_textItem;
|
||||
@@ -187,9 +206,17 @@ void ChatDocumentHandler::setTextItem(QQuickItem *textItem)
|
||||
connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(updateCursor()));
|
||||
if (document()) {
|
||||
connect(document(), &QTextDocument::contentsChanged, this, &ChatDocumentHandler::contentsChanged);
|
||||
connect(document(), &QTextDocument::contentsChanged, this, [this]() {
|
||||
if (m_room) {
|
||||
updateCursor();
|
||||
connect(document(), &QTextDocument::contentsChanged, this, &ChatDocumentHandler::updateCursor);
|
||||
connect(document(), &QTextDocument::contentsChange, this, [this](int position) {
|
||||
auto cursor = textCursor();
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
cursor.setPosition(position);
|
||||
cursor.select(QTextCursor::WordUnderCursor);
|
||||
if (!cursor.selectedText().isEmpty()) {
|
||||
cursor.mergeCharFormat(m_pendingFormat);
|
||||
m_pendingFormat = {};
|
||||
}
|
||||
});
|
||||
initializeChars();
|
||||
@@ -308,7 +335,6 @@ void ChatDocumentHandler::updateCursor()
|
||||
int start = completionStartIndex();
|
||||
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
|
||||
|
||||
Q_EMIT formatChanged();
|
||||
Q_EMIT atFirstLineChanged();
|
||||
Q_EMIT atLastLineChanged();
|
||||
}
|
||||
@@ -329,20 +355,20 @@ int ChatDocumentHandler::selectionEnd() const
|
||||
return m_textItem->property("selectionEnd").toInt();
|
||||
}
|
||||
|
||||
NeoChatRoom *ChatDocumentHandler::room() const
|
||||
QTextCursor ChatDocumentHandler::textCursor() const
|
||||
{
|
||||
return m_room;
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setRoom(NeoChatRoom *room)
|
||||
{
|
||||
if (m_room == room) {
|
||||
return;
|
||||
if (!document()) {
|
||||
return QTextCursor();
|
||||
}
|
||||
|
||||
m_room = room;
|
||||
m_completionModel->setRoom(m_room);
|
||||
Q_EMIT roomChanged();
|
||||
QTextCursor cursor = QTextCursor(document());
|
||||
if (selectionStart() != selectionEnd()) {
|
||||
cursor.setPosition(selectionStart());
|
||||
cursor.setPosition(selectionEnd(), QTextCursor::KeepAnchor);
|
||||
} else {
|
||||
cursor.setPosition(cursorPosition());
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
bool ChatDocumentHandler::isEmpty() const
|
||||
@@ -647,35 +673,6 @@ void ChatDocumentHandler::updateMentions(const QString &editId)
|
||||
}
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setFontSize(int size)
|
||||
{
|
||||
if (size <= 0)
|
||||
return;
|
||||
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.isNull())
|
||||
return;
|
||||
|
||||
if (!cursor.hasSelection())
|
||||
cursor.select(QTextCursor::WordUnderCursor);
|
||||
|
||||
if (cursor.charFormat().property(QTextFormat::FontPointSize).toInt() == size)
|
||||
return;
|
||||
|
||||
QTextCharFormat format;
|
||||
format.setFontPointSize(size);
|
||||
mergeFormatOnWordOrSelection(format);
|
||||
Q_EMIT fontSizeChanged();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setStrikethrough(bool strikethrough)
|
||||
{
|
||||
QTextCharFormat format;
|
||||
format.setFontStrikeOut(strikethrough);
|
||||
mergeFormatOnWordOrSelection(format);
|
||||
Q_EMIT formatChanged();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setTextColor(const QColor &color)
|
||||
{
|
||||
QTextCharFormat format;
|
||||
@@ -684,23 +681,6 @@ void ChatDocumentHandler::setTextColor(const QColor &color)
|
||||
Q_EMIT textColorChanged();
|
||||
}
|
||||
|
||||
Qt::Alignment ChatDocumentHandler::alignment() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.isNull())
|
||||
return Qt::AlignLeft;
|
||||
return textCursor().blockFormat().alignment();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setAlignment(Qt::Alignment alignment)
|
||||
{
|
||||
QTextBlockFormat format;
|
||||
format.setAlignment(alignment);
|
||||
QTextCursor cursor = textCursor();
|
||||
cursor.mergeBlockFormat(format);
|
||||
Q_EMIT alignmentChanged();
|
||||
}
|
||||
|
||||
bool ChatDocumentHandler::bold() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
@@ -710,14 +690,6 @@ bool ChatDocumentHandler::bold() const
|
||||
return textCursor().charFormat().fontWeight() == QFont::Bold;
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setBold(bool bold)
|
||||
{
|
||||
QTextCharFormat format;
|
||||
format.setFontWeight(bold ? QFont::Bold : QFont::Normal);
|
||||
mergeFormatOnWordOrSelection(format);
|
||||
Q_EMIT formatChanged();
|
||||
}
|
||||
|
||||
bool ChatDocumentHandler::italic() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
@@ -726,14 +698,6 @@ bool ChatDocumentHandler::italic() const
|
||||
return textCursor().charFormat().fontItalic();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setItalic(bool italic)
|
||||
{
|
||||
QTextCharFormat format;
|
||||
format.setFontItalic(italic);
|
||||
mergeFormatOnWordOrSelection(format);
|
||||
Q_EMIT formatChanged();
|
||||
}
|
||||
|
||||
bool ChatDocumentHandler::underline() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
@@ -742,14 +706,6 @@ bool ChatDocumentHandler::underline() const
|
||||
return textCursor().charFormat().fontUnderline();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setUnderline(bool underline)
|
||||
{
|
||||
QTextCharFormat format;
|
||||
format.setFontUnderline(underline);
|
||||
mergeFormatOnWordOrSelection(format);
|
||||
Q_EMIT formatChanged();
|
||||
}
|
||||
|
||||
bool ChatDocumentHandler::strikethrough() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
@@ -758,23 +714,6 @@ bool ChatDocumentHandler::strikethrough() const
|
||||
return textCursor().charFormat().fontStrikeOut();
|
||||
}
|
||||
|
||||
QString ChatDocumentHandler::fontFamily() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.isNull())
|
||||
return QString();
|
||||
QTextCharFormat format = cursor.charFormat();
|
||||
return format.font().family();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setFontFamily(const QString &family)
|
||||
{
|
||||
QTextCharFormat format;
|
||||
format.setFontFamilies({family});
|
||||
mergeFormatOnWordOrSelection(format);
|
||||
Q_EMIT fontFamilyChanged();
|
||||
}
|
||||
|
||||
QColor ChatDocumentHandler::textColor() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
@@ -784,22 +723,6 @@ QColor ChatDocumentHandler::textColor() const
|
||||
return format.foreground().color();
|
||||
}
|
||||
|
||||
QTextCursor ChatDocumentHandler::textCursor() const
|
||||
{
|
||||
if (!document()) {
|
||||
return QTextCursor();
|
||||
}
|
||||
|
||||
QTextCursor cursor = QTextCursor(document());
|
||||
if (selectionStart() != selectionEnd()) {
|
||||
cursor.setPosition(selectionStart());
|
||||
cursor.setPosition(selectionEnd(), QTextCursor::KeepAnchor);
|
||||
} else {
|
||||
cursor.setPosition(cursorPosition());
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
std::optional<Qt::TextFormat> ChatDocumentHandler::textFormat() const
|
||||
{
|
||||
if (!m_textItem) {
|
||||
@@ -812,9 +735,14 @@ std::optional<Qt::TextFormat> ChatDocumentHandler::textFormat() const
|
||||
void ChatDocumentHandler::mergeFormatOnWordOrSelection(const QTextCharFormat &format)
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
if (!cursor.hasSelection())
|
||||
if (!cursor.hasSelection()) {
|
||||
cursor.select(QTextCursor::WordUnderCursor);
|
||||
cursor.mergeCharFormat(format);
|
||||
}
|
||||
if (cursor.hasSelection()) {
|
||||
cursor.mergeCharFormat(format);
|
||||
} else {
|
||||
m_pendingFormat = format.toCharFormat();
|
||||
}
|
||||
}
|
||||
|
||||
QString ChatDocumentHandler::currentLinkText() const
|
||||
@@ -993,19 +921,38 @@ void ChatDocumentHandler::regenerateColorScheme()
|
||||
// TODO update existing link
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setFormat(RichFormat::Format format)
|
||||
{
|
||||
switch (RichFormat::typeForFormat(format)) {
|
||||
case RichFormat::Text:
|
||||
setTextFormat(format);
|
||||
return;
|
||||
case RichFormat::List:
|
||||
setListFormat(format);
|
||||
return;
|
||||
case RichFormat::Style:
|
||||
setStyleFormat(format);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::indentListMore()
|
||||
{
|
||||
m_nestedListHelper.handleOnIndentMore(textCursor());
|
||||
Q_EMIT currentListStyleChanged();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::indentListLess()
|
||||
{
|
||||
m_nestedListHelper.handleOnIndentLess(textCursor());
|
||||
Q_EMIT currentListStyleChanged();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setListStyle(int styleIndex)
|
||||
void ChatDocumentHandler::setListFormat(RichFormat::Format format)
|
||||
{
|
||||
m_nestedListHelper.handleOnBulletType(-styleIndex, textCursor());
|
||||
m_nestedListHelper.handleOnBulletType(RichFormat::listStyleForFormat(format), textCursor());
|
||||
Q_EMIT currentListStyleChanged();
|
||||
}
|
||||
|
||||
@@ -1028,28 +975,38 @@ int ChatDocumentHandler::currentListStyle() const
|
||||
return -textCursor().currentList()->format().style();
|
||||
}
|
||||
|
||||
TextStyle::Style ChatDocumentHandler::style() const
|
||||
void ChatDocumentHandler::setTextFormat(RichFormat::Format format)
|
||||
{
|
||||
return static_cast<TextStyle::Style>(textCursor().blockFormat().headingLevel());
|
||||
if (RichFormat::typeForFormat(format) != RichFormat::Text) {
|
||||
return;
|
||||
}
|
||||
mergeFormatOnWordOrSelection(RichFormat::charFormatForFormat(format, RichFormat::hasFormat(textCursor(), format)));
|
||||
Q_EMIT formatChanged();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setStyle(TextStyle::Style style)
|
||||
RichFormat::Format ChatDocumentHandler::style() const
|
||||
{
|
||||
const int headingLevel = style <= 6 ? style : 0;
|
||||
// Apparently, 5 is maximum for FontSizeAdjustment; otherwise level=1 and
|
||||
// level=2 look the same
|
||||
const int sizeAdjustment = headingLevel > 0 ? 5 - headingLevel : 0;
|
||||
return static_cast<RichFormat::Format>(textCursor().blockFormat().headingLevel());
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setStyleFormat(RichFormat::Format format)
|
||||
{
|
||||
// Paragraph is special because it is normally a Block format but if we're already
|
||||
// in a Paragraph it clears any existing style.
|
||||
if (!(RichFormat::typeForFormat(format) == RichFormat::Style || format == RichFormat::Paragraph)) {
|
||||
return;
|
||||
}
|
||||
|
||||
qWarning() << format;
|
||||
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
cursor.beginEditBlock();
|
||||
|
||||
QTextBlockFormat blkfmt;
|
||||
blkfmt.setHeadingLevel(headingLevel);
|
||||
cursor.mergeBlockFormat(blkfmt);
|
||||
cursor.mergeBlockFormat(RichFormat::blockFormatForFormat(format));
|
||||
|
||||
QTextCharFormat chrfmt;
|
||||
chrfmt.setFontWeight(headingLevel > 0 ? QFont::Bold : QFont::Normal);
|
||||
chrfmt.setProperty(QTextFormat::FontSizeAdjustment, sizeAdjustment);
|
||||
// Applying style to the current line or selection
|
||||
QTextCursor selectCursor = cursor;
|
||||
if (selectCursor.hasSelection()) {
|
||||
@@ -1066,23 +1023,16 @@ void ChatDocumentHandler::setStyle(TextStyle::Style style)
|
||||
} else {
|
||||
selectCursor.select(QTextCursor::BlockUnderCursor);
|
||||
}
|
||||
selectCursor.mergeCharFormat(chrfmt);
|
||||
|
||||
const auto chrfmt = RichFormat::charFormatForFormat(format);
|
||||
selectCursor.mergeCharFormat(chrfmt);
|
||||
cursor.mergeBlockCharFormat(chrfmt);
|
||||
cursor.endEditBlock();
|
||||
|
||||
Q_EMIT formatChanged();
|
||||
Q_EMIT styleChanged();
|
||||
}
|
||||
|
||||
int ChatDocumentHandler::fontSize() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.isNull())
|
||||
return 0;
|
||||
QTextCharFormat format = cursor.charFormat();
|
||||
return format.font().pointSize();
|
||||
}
|
||||
|
||||
QString ChatDocumentHandler::fileName() const
|
||||
{
|
||||
const QString filePath = QQmlFile::urlToLocalFileOrQrc(m_fileUrl);
|
||||
@@ -1102,6 +1052,19 @@ QUrl ChatDocumentHandler::fileUrl() const
|
||||
return m_fileUrl;
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::tab()
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
if (cursor.currentList()) {
|
||||
indentListMore();
|
||||
return;
|
||||
}
|
||||
insertText(u" "_s);
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::deleteChar()
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
@@ -1124,6 +1087,11 @@ void ChatDocumentHandler::backspace()
|
||||
return;
|
||||
}
|
||||
if (cursor.position() <= m_fixedStartChars.length()) {
|
||||
qWarning() << "unhandled backspace";
|
||||
if (cursor.currentList()) {
|
||||
indentListLess();
|
||||
return;
|
||||
}
|
||||
if (const auto previousHandler = previousDocumentHandler()) {
|
||||
previousHandler->insertFragment(takeFirstBlock(), End, true);
|
||||
} else {
|
||||
@@ -1134,6 +1102,15 @@ void ChatDocumentHandler::backspace()
|
||||
cursor.deletePreviousChar();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::insertReturn()
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
cursor.insertBlock();
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::insertText(const QString &text)
|
||||
{
|
||||
textCursor().insertText(text);
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
#include <qtextdocumentfragment.h>
|
||||
|
||||
#include "chatbarcache.h"
|
||||
#include "chatmarkdownhelper.h"
|
||||
#include "enums/chatbartype.h"
|
||||
#include "enums/textstyle.h"
|
||||
#include "enums/richformat.h"
|
||||
#include "models/completionmodel.h"
|
||||
#include "neochatroom.h"
|
||||
#include "nestedlisthelper_p.h"
|
||||
@@ -103,25 +104,19 @@ class ChatDocumentHandler : public QObject
|
||||
Q_PROPERTY(bool atLastLine READ atLastLine NOTIFY atLastLineChanged)
|
||||
|
||||
Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor NOTIFY textColorChanged)
|
||||
Q_PROPERTY(QString fontFamily READ fontFamily WRITE setFontFamily NOTIFY fontFamilyChanged)
|
||||
Q_PROPERTY(Qt::Alignment alignment READ alignment WRITE setAlignment NOTIFY alignmentChanged)
|
||||
|
||||
Q_PROPERTY(bool bold READ bold WRITE setBold NOTIFY formatChanged)
|
||||
Q_PROPERTY(bool italic READ italic WRITE setItalic NOTIFY formatChanged)
|
||||
Q_PROPERTY(bool underline READ underline WRITE setUnderline NOTIFY formatChanged)
|
||||
Q_PROPERTY(bool strikethrough READ strikethrough WRITE setStrikethrough NOTIFY formatChanged)
|
||||
Q_PROPERTY(bool bold READ bold NOTIFY formatChanged)
|
||||
Q_PROPERTY(bool italic READ italic NOTIFY formatChanged)
|
||||
Q_PROPERTY(bool underline READ underline NOTIFY formatChanged)
|
||||
Q_PROPERTY(bool strikethrough READ strikethrough NOTIFY formatChanged)
|
||||
|
||||
Q_PROPERTY(TextStyle::Style style READ style WRITE setStyle NOTIFY styleChanged)
|
||||
Q_PROPERTY(RichFormat::Format style READ style NOTIFY styleChanged)
|
||||
|
||||
// Q_PROPERTY(bool canIndentList READ canIndentList NOTIFY cursorPositionChanged)
|
||||
// Q_PROPERTY(bool canDedentList READ canDedentList NOTIFY cursorPositionChanged)
|
||||
Q_PROPERTY(int currentListStyle READ currentListStyle NOTIFY currentListStyleChanged)
|
||||
// Q_PROPERTY(int currentHeadingLevel READ currentHeadingLevel NOTIFY cursorPositionChanged)
|
||||
|
||||
// Q_PROPERTY(bool list READ list WRITE setList NOTIFY listChanged)
|
||||
|
||||
Q_PROPERTY(int fontSize READ fontSize WRITE setFontSize NOTIFY fontSizeChanged)
|
||||
|
||||
Q_PROPERTY(QString fileName READ fileName NOTIFY fileUrlChanged)
|
||||
Q_PROPERTY(QString fileType READ fileType NOTIFY fileUrlChanged)
|
||||
Q_PROPERTY(QUrl fileUrl READ fileUrl NOTIFY fileUrlChanged)
|
||||
@@ -176,45 +171,33 @@ public:
|
||||
*/
|
||||
Q_INVOKABLE void updateMentions(const QString &editId);
|
||||
|
||||
QString fontFamily() const;
|
||||
void setFontFamily(const QString &family);
|
||||
|
||||
QColor textColor() const;
|
||||
void setTextColor(const QColor &color);
|
||||
|
||||
Qt::Alignment alignment() const;
|
||||
void setAlignment(Qt::Alignment alignment);
|
||||
|
||||
bool bold() const;
|
||||
void setBold(bool bold);
|
||||
bool italic() const;
|
||||
void setItalic(bool italic);
|
||||
|
||||
bool underline() const;
|
||||
void setUnderline(bool underline);
|
||||
|
||||
bool strikethrough() const;
|
||||
void setStrikethrough(bool strikethrough);
|
||||
|
||||
Q_INVOKABLE void setFormat(RichFormat::Format format);
|
||||
void setFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor);
|
||||
|
||||
bool canIndentList() const;
|
||||
bool canDedentList() const;
|
||||
int currentListStyle() const;
|
||||
Q_INVOKABLE void indentListLess();
|
||||
Q_INVOKABLE void indentListMore();
|
||||
|
||||
TextStyle::Style style() const;
|
||||
void setStyle(TextStyle::Style style);
|
||||
|
||||
// bool list() const;
|
||||
// void setList(bool list);
|
||||
|
||||
int fontSize() const;
|
||||
void setFontSize(int size);
|
||||
RichFormat::Format style() const;
|
||||
|
||||
QString fileName() const;
|
||||
QString fileType() const;
|
||||
QUrl fileUrl() const;
|
||||
|
||||
Q_INVOKABLE void tab();
|
||||
Q_INVOKABLE void deleteChar();
|
||||
Q_INVOKABLE void backspace();
|
||||
Q_INVOKABLE void insertReturn();
|
||||
Q_INVOKABLE void insertText(const QString &text);
|
||||
void insertFragment(const QTextDocumentFragment fragment, InsertPosition position = Cursor, bool keepPosition = false);
|
||||
Q_INVOKABLE QString currentLinkUrl() const;
|
||||
@@ -223,11 +206,6 @@ public:
|
||||
Q_INVOKABLE void insertImage(const QUrl &imagePath);
|
||||
Q_INVOKABLE void insertTable(int rows, int columns);
|
||||
|
||||
Q_INVOKABLE void indentListLess();
|
||||
Q_INVOKABLE void indentListMore();
|
||||
|
||||
Q_INVOKABLE void setListStyle(int styleIndex);
|
||||
|
||||
Q_INVOKABLE void dumpHtml();
|
||||
Q_INVOKABLE QString htmlText() const;
|
||||
|
||||
@@ -239,17 +217,10 @@ Q_SIGNALS:
|
||||
void atFirstLineChanged();
|
||||
void atLastLineChanged();
|
||||
|
||||
void fontFamilyChanged();
|
||||
void textColorChanged();
|
||||
void alignmentChanged();
|
||||
|
||||
void boldChanged();
|
||||
void italicChanged();
|
||||
void underlineChanged();
|
||||
void checkableChanged();
|
||||
void strikethroughChanged();
|
||||
void currentListStyleChanged();
|
||||
void fontSizeChanged();
|
||||
void fileUrlChanged();
|
||||
|
||||
void formatChanged();
|
||||
@@ -262,6 +233,7 @@ Q_SIGNALS:
|
||||
|
||||
private:
|
||||
ChatBarType::Type m_type = ChatBarType::None;
|
||||
QPointer<NeoChatRoom> m_room;
|
||||
QPointer<QQuickItem> m_textItem;
|
||||
QTextDocument *document() const;
|
||||
|
||||
@@ -273,22 +245,26 @@ private:
|
||||
QString m_initialText = {};
|
||||
void initializeChars();
|
||||
|
||||
int completionStartIndex() const;
|
||||
|
||||
QPointer<NeoChatRoom> m_room;
|
||||
|
||||
int cursorPosition() const;
|
||||
int selectionStart() const;
|
||||
int selectionEnd() const;
|
||||
QTextCursor textCursor() const;
|
||||
|
||||
void setTextFormat(RichFormat::Format format);
|
||||
void setStyleFormat(RichFormat::Format format);
|
||||
void setListFormat(RichFormat::Format format);
|
||||
|
||||
QPointer<ChatMarkdownHelper> m_markdownHelper;
|
||||
QTextCharFormat m_pendingFormat = {};
|
||||
|
||||
SyntaxHighlighter *m_highlighter = nullptr;
|
||||
|
||||
int completionStartIndex() const;
|
||||
|
||||
CompletionModel *m_completionModel = nullptr;
|
||||
QString getText() const;
|
||||
void pushMention(const Mention mention) const;
|
||||
|
||||
SyntaxHighlighter *m_highlighter = nullptr;
|
||||
QQuickItem *m_textArea;
|
||||
|
||||
CompletionModel *m_completionModel = nullptr;
|
||||
QTextCursor textCursor() const;
|
||||
std::optional<Qt::TextFormat> textFormat() const;
|
||||
void mergeFormatOnWordOrSelection(const QTextCharFormat &format);
|
||||
void selectLinkText(QTextCursor *cursor) const;
|
||||
|
||||
235
src/libneochat/chatmarkdownhelper.cpp
Normal file
235
src/libneochat/chatmarkdownhelper.cpp
Normal file
@@ -0,0 +1,235 @@
|
||||
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#include "chatmarkdownhelper.h"
|
||||
|
||||
#include <QTextDocument>
|
||||
#include <qtextcursor.h>
|
||||
|
||||
#include "chatdocumenthandler.h"
|
||||
|
||||
struct MarkdownSyntax {
|
||||
QLatin1String sequence;
|
||||
bool closable = false;
|
||||
bool lineStart = false;
|
||||
RichFormat::Format format;
|
||||
};
|
||||
|
||||
static const QList<MarkdownSyntax> syntax = {
|
||||
MarkdownSyntax{.sequence = "*"_L1, .closable = true, .format = RichFormat::Italic},
|
||||
MarkdownSyntax{.sequence = "**"_L1, .closable = true, .format = RichFormat::Bold},
|
||||
MarkdownSyntax{.sequence = "# "_L1, .lineStart = true, .format = RichFormat::Heading1},
|
||||
MarkdownSyntax{.sequence = "## "_L1, .lineStart = true, .format = RichFormat::Heading2},
|
||||
MarkdownSyntax{.sequence = "### "_L1, .lineStart = true, .format = RichFormat::Heading3},
|
||||
MarkdownSyntax{.sequence = "#### "_L1, .lineStart = true, .format = RichFormat::Heading4},
|
||||
MarkdownSyntax{.sequence = "##### "_L1, .lineStart = true, .format = RichFormat::Heading5},
|
||||
MarkdownSyntax{.sequence = "###### "_L1, .lineStart = true, .format = RichFormat::Heading6},
|
||||
MarkdownSyntax{.sequence = "> "_L1, .lineStart = true, .format = RichFormat::Quote},
|
||||
MarkdownSyntax{.sequence = "* "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
|
||||
MarkdownSyntax{.sequence = "- "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
|
||||
MarkdownSyntax{.sequence = "1. "_L1, .lineStart = true, .format = RichFormat::OrderedList},
|
||||
MarkdownSyntax{.sequence = "1) "_L1, .lineStart = true, .format = RichFormat::OrderedList},
|
||||
MarkdownSyntax{.sequence = "`"_L1, .closable = true, .format = RichFormat::InlineCode},
|
||||
MarkdownSyntax{.sequence = "```"_L1, .lineStart = true, .format = RichFormat::Code},
|
||||
MarkdownSyntax{.sequence = "~~"_L1, .closable = true, .format = RichFormat::Strikethrough},
|
||||
MarkdownSyntax{.sequence = "__"_L1, .closable = true, .format = RichFormat::Underline},
|
||||
};
|
||||
|
||||
static std::optional<bool> checkSequence(const QString ¤tString, const QString &nextChar, bool lineStart = false)
|
||||
{
|
||||
QList<MarkdownSyntax> partialMatches;
|
||||
std::optional<MarkdownSyntax> fullMatch = std::nullopt;
|
||||
auto it = syntax.cbegin();
|
||||
while ((it = std::find_if(it,
|
||||
syntax.cend(),
|
||||
[currentString, nextChar](const MarkdownSyntax &syntax) {
|
||||
return syntax.sequence == currentString || syntax.sequence.startsWith(QString(currentString + nextChar));
|
||||
}))
|
||||
!= syntax.cend()) {
|
||||
if (it->lineStart ? lineStart : true) {
|
||||
if (it->sequence == currentString) {
|
||||
fullMatch = *it;
|
||||
} else {
|
||||
partialMatches += *it;
|
||||
}
|
||||
}
|
||||
++it;
|
||||
}
|
||||
|
||||
if (partialMatches.length() > 0) {
|
||||
return false;
|
||||
}
|
||||
if (fullMatch) {
|
||||
return true;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
static std::optional<MarkdownSyntax> syntaxForSequence(const QString &sequence)
|
||||
{
|
||||
const auto it = std::find_if(syntax.cbegin(), syntax.cend(), [sequence](const MarkdownSyntax &syntax) {
|
||||
return syntax.sequence == sequence;
|
||||
});
|
||||
if (it == syntax.cend()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return *it;
|
||||
}
|
||||
|
||||
ChatMarkdownHelper::ChatMarkdownHelper(ChatDocumentHandler *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
Q_ASSERT(parent);
|
||||
|
||||
connectDocument();
|
||||
connect(parent, &ChatDocumentHandler::textItemChanged, this, &ChatMarkdownHelper::connectDocument);
|
||||
}
|
||||
|
||||
QTextDocument *ChatMarkdownHelper::document() const
|
||||
{
|
||||
const auto documentHandler = qobject_cast<ChatDocumentHandler *>(parent());
|
||||
if (!documentHandler) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!documentHandler->textItem()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto quickDocument = qvariant_cast<QQuickTextDocument *>(documentHandler->textItem()->property("textDocument"));
|
||||
return quickDocument ? quickDocument->textDocument() : nullptr;
|
||||
}
|
||||
|
||||
void ChatMarkdownHelper::connectDocument()
|
||||
{
|
||||
disconnect();
|
||||
|
||||
if (document()) {
|
||||
m_startPos = qobject_cast<ChatDocumentHandler *>(parent())->textItem()->property("cursorPosition").toInt();
|
||||
m_endPos = m_startPos;
|
||||
if (m_startPos == 0) {
|
||||
m_currentState = Pre;
|
||||
}
|
||||
|
||||
connect(document(), &QTextDocument::contentsChange, this, &ChatMarkdownHelper::checkMarkdown);
|
||||
}
|
||||
}
|
||||
|
||||
void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int charsAdded)
|
||||
{
|
||||
qWarning() << "1" << m_currentState << m_startPos << m_endPos;
|
||||
|
||||
if (!document()) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto cursor = QTextCursor(document());
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (charsRemoved - charsAdded > 0) {
|
||||
if (position < m_startPos) {
|
||||
m_startPos = position;
|
||||
}
|
||||
m_endPos -= charsRemoved;
|
||||
cursor.setPosition(m_endPos);
|
||||
cursor.setPosition(m_endPos + (cursor.atBlockEnd() ? 0 : 1), QTextCursor::KeepAnchor);
|
||||
const auto nextChar = cursor.selectedText();
|
||||
m_currentState = m_startPos == 0 || nextChar == u' ' ? Pre : None;
|
||||
qWarning() << "2" << m_currentState << m_startPos << m_endPos;
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto i = 1; i <= charsAdded - charsRemoved; ++i) {
|
||||
cursor.setPosition(m_startPos);
|
||||
cursor.setPosition(m_endPos, QTextCursor::KeepAnchor);
|
||||
const auto currentMarkdown = cursor.selectedText();
|
||||
cursor.setPosition(m_endPos);
|
||||
cursor.setPosition(m_endPos + 1, QTextCursor::KeepAnchor);
|
||||
const auto nextChar = cursor.selectedText();
|
||||
cursor.setPosition(m_startPos);
|
||||
|
||||
const auto result = checkSequence(currentMarkdown, nextChar, cursor.atBlockStart());
|
||||
qWarning() << result;
|
||||
|
||||
switch (m_currentState) {
|
||||
case None:
|
||||
if (nextChar == u' ' || cursor.atBlockEnd()) {
|
||||
m_currentState = Pre;
|
||||
}
|
||||
++m_startPos;
|
||||
m_endPos = m_startPos;
|
||||
break;
|
||||
case Pre:
|
||||
if (!result && RichFormat::formatsAtCursor(cursor).length() == 0) {
|
||||
m_currentState = None;
|
||||
} else if (result && !*result) {
|
||||
m_currentState = Started;
|
||||
++m_endPos;
|
||||
break;
|
||||
}
|
||||
++m_startPos;
|
||||
m_endPos = m_startPos;
|
||||
break;
|
||||
case Started:
|
||||
if (!result) {
|
||||
m_currentState = Pre;
|
||||
++m_startPos;
|
||||
m_endPos = m_startPos;
|
||||
break;
|
||||
} else if (!*result) {
|
||||
++m_endPos;
|
||||
break;
|
||||
}
|
||||
complete();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
qWarning() << "2" << m_currentState << m_startPos << m_endPos;
|
||||
}
|
||||
|
||||
void ChatMarkdownHelper::complete()
|
||||
{
|
||||
auto cursor = QTextCursor(document());
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
cursor.setPosition(m_startPos);
|
||||
cursor.setPosition(m_endPos, QTextCursor::KeepAnchor);
|
||||
const auto syntax = syntaxForSequence(cursor.selectedText());
|
||||
cursor.removeSelectedText();
|
||||
|
||||
if (m_currentFormats.contains(syntax->format)) {
|
||||
m_currentFormats.remove(syntax->format);
|
||||
} else if (syntax->closable) {
|
||||
m_currentFormats.insert(syntax->format, m_startPos);
|
||||
}
|
||||
|
||||
++m_startPos;
|
||||
|
||||
const auto documentHandler = qobject_cast<ChatDocumentHandler *>(parent());
|
||||
if (syntax) {
|
||||
documentHandler->textItem()->setProperty("cursorPosition", m_startPos);
|
||||
documentHandler->setFormat(syntax->format);
|
||||
}
|
||||
|
||||
m_currentState = Pre;
|
||||
m_endPos = m_startPos;
|
||||
|
||||
documentHandler->textItem()->setProperty("cursorPosition", m_startPos);
|
||||
}
|
||||
|
||||
void ChatMarkdownHelper::handleExternalFormatChange()
|
||||
{
|
||||
auto cursor = QTextCursor(document());
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
cursor.setPosition(m_startPos);
|
||||
m_currentState = RichFormat::formatsAtCursor(cursor).length() > 0 ? Pre : None;
|
||||
qWarning() << "3" << m_currentState << m_startPos << m_endPos;
|
||||
}
|
||||
|
||||
#include "moc_chatmarkdownhelper.cpp"
|
||||
43
src/libneochat/chatmarkdownhelper.h
Normal file
43
src/libneochat/chatmarkdownhelper.h
Normal file
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
|
||||
#include "enums/richformat.h"
|
||||
|
||||
class QTextDocument;
|
||||
|
||||
class ChatDocumentHandler;
|
||||
|
||||
class ChatMarkdownHelper : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
public:
|
||||
explicit ChatMarkdownHelper(ChatDocumentHandler *parent);
|
||||
|
||||
void handleExternalFormatChange();
|
||||
|
||||
private:
|
||||
enum State {
|
||||
None,
|
||||
Pre,
|
||||
Started,
|
||||
};
|
||||
|
||||
QTextDocument *document() const;
|
||||
void connectDocument();
|
||||
|
||||
State m_currentState = None;
|
||||
int m_startPos = 0;
|
||||
int m_endPos = 0;
|
||||
|
||||
QHash<RichFormat::Format, int> m_currentFormats;
|
||||
|
||||
void checkMarkdown(int position, int charsRemoved, int charsAdded);
|
||||
void complete();
|
||||
};
|
||||
180
src/libneochat/enums/richformat.cpp
Normal file
180
src/libneochat/enums/richformat.cpp
Normal file
@@ -0,0 +1,180 @@
|
||||
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#include "richformat.h"
|
||||
|
||||
#include <QTextBlockFormat>
|
||||
#include <QTextCharFormat>
|
||||
#include <QTextCursor>
|
||||
#include <qtextformat.h>
|
||||
|
||||
QString RichFormat::styleString(Format format)
|
||||
{
|
||||
switch (format) {
|
||||
case Paragraph:
|
||||
return u"Paragraph"_s;
|
||||
case Heading1:
|
||||
return u"Heading 1"_s;
|
||||
case Heading2:
|
||||
return u"Heading 2"_s;
|
||||
case Heading3:
|
||||
return u"Heading 3"_s;
|
||||
case Heading4:
|
||||
return u"Heading 4"_s;
|
||||
case Heading5:
|
||||
return u"Heading 5"_s;
|
||||
case Heading6:
|
||||
return u"Heading 6"_s;
|
||||
case Code:
|
||||
return u"Code"_s;
|
||||
case Quote:
|
||||
return u"\"Quote\""_s;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
RichFormat::FormatType RichFormat::typeForFormat(Format format)
|
||||
{
|
||||
switch (format) {
|
||||
case Code:
|
||||
case Quote:
|
||||
return Block;
|
||||
case Paragraph:
|
||||
case Heading1:
|
||||
case Heading2:
|
||||
case Heading3:
|
||||
case Heading4:
|
||||
case Heading5:
|
||||
case Heading6:
|
||||
return Style;
|
||||
case UnorderedList:
|
||||
case OrderedList:
|
||||
return List;
|
||||
default:
|
||||
return Text;
|
||||
}
|
||||
};
|
||||
|
||||
QTextListFormat::Style RichFormat::listStyleForFormat(Format format)
|
||||
{
|
||||
switch (format) {
|
||||
case UnorderedList:
|
||||
return QTextListFormat::ListDisc;
|
||||
case OrderedList:
|
||||
return QTextListFormat::ListDecimal;
|
||||
default:
|
||||
return QTextListFormat::ListStyleUndefined;
|
||||
}
|
||||
}
|
||||
|
||||
QTextCharFormat RichFormat::charFormatForFormat(Format format, bool invert)
|
||||
{
|
||||
QTextCharFormat charFormat;
|
||||
if (format == Bold || headingLevelForFormat(format) > 0) {
|
||||
const auto weight = invert ? QFont::Normal : QFont::Bold;
|
||||
charFormat.setFontWeight(weight);
|
||||
}
|
||||
if (format == Italic) {
|
||||
charFormat.setFontItalic(!invert);
|
||||
}
|
||||
if (format == Underline) {
|
||||
charFormat.setFontUnderline(!invert);
|
||||
}
|
||||
if (format == Strikethrough) {
|
||||
charFormat.setFontStrikeOut(!invert);
|
||||
}
|
||||
if (headingLevelForFormat(format) > 0) {
|
||||
// Apparently, 4 is maximum for FontSizeAdjustment; otherwise level=1 and
|
||||
// level=2 look the same
|
||||
charFormat.setProperty(QTextFormat::FontSizeAdjustment, 5 - headingLevelForFormat(format));
|
||||
}
|
||||
if (format == Paragraph) {
|
||||
charFormat.setFontWeight(QFont::Normal);
|
||||
charFormat.setProperty(QTextFormat::FontSizeAdjustment, 0);
|
||||
}
|
||||
return charFormat;
|
||||
}
|
||||
|
||||
QTextBlockFormat RichFormat::blockFormatForFormat(Format format)
|
||||
{
|
||||
QTextBlockFormat blockformat;
|
||||
blockformat.setHeadingLevel(headingLevelForFormat(format));
|
||||
return blockformat;
|
||||
}
|
||||
|
||||
int RichFormat::headingLevelForFormat(Format format)
|
||||
{
|
||||
const auto intFormat = int(format);
|
||||
return intFormat <= 6 ? intFormat : 0;
|
||||
}
|
||||
|
||||
RichFormat::Format RichFormat::formatForHeadingLevel(int level)
|
||||
{
|
||||
auto clampLevel = level > 6 ? 0 : level;
|
||||
clampLevel = std::clamp(clampLevel, 0, 6);
|
||||
return static_cast<Format>(clampLevel);
|
||||
}
|
||||
|
||||
bool RichFormat::hasFormat(QTextCursor cursor, Format format)
|
||||
{
|
||||
switch (format) {
|
||||
case Paragraph:
|
||||
return cursor.blockFormat().headingLevel() == headingLevelForFormat(format);
|
||||
case Heading1:
|
||||
case Heading2:
|
||||
case Heading3:
|
||||
case Heading4:
|
||||
case Heading5:
|
||||
case Heading6:
|
||||
return cursor.blockFormat().headingLevel() == headingLevelForFormat(format) && cursor.charFormat().fontWeight() == QFont::Bold;
|
||||
case Quote:
|
||||
return cursor.blockFormat().headingLevel() == headingLevelForFormat(format) && cursor.charFormat().fontItalic();
|
||||
case Code:
|
||||
return cursor.blockFormat().headingLevel() == headingLevelForFormat(format);
|
||||
case Bold:
|
||||
return cursor.charFormat().fontWeight() == QFont::Bold;
|
||||
case Italic:
|
||||
return cursor.charFormat().fontItalic();
|
||||
case UnorderedList:
|
||||
return cursor.currentList()->format().style() == QTextListFormat::ListDisc;
|
||||
case OrderedList:
|
||||
return cursor.currentList()->format().style() == QTextListFormat::ListDecimal;
|
||||
case Strikethrough:
|
||||
return cursor.charFormat().fontStrikeOut();
|
||||
case Underline:
|
||||
return cursor.charFormat().fontUnderline();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QList<RichFormat::Format> RichFormat::formatsAtCursor(const QTextCursor &cursor)
|
||||
{
|
||||
QList<Format> formats;
|
||||
if (cursor.isNull()) {
|
||||
return formats;
|
||||
}
|
||||
if (cursor.charFormat().fontWeight() == QFont::Bold) {
|
||||
formats += Bold;
|
||||
}
|
||||
if (cursor.charFormat().fontItalic()) {
|
||||
formats += Italic;
|
||||
}
|
||||
if (cursor.charFormat().fontUnderline()) {
|
||||
formats += Underline;
|
||||
}
|
||||
if (cursor.charFormat().fontStrikeOut()) {
|
||||
formats += Strikethrough;
|
||||
}
|
||||
if (cursor.blockFormat().headingLevel() > 0 && cursor.blockFormat().headingLevel() <= 6) {
|
||||
formats += formatForHeadingLevel(cursor.blockFormat().headingLevel());
|
||||
}
|
||||
if (cursor.currentList() && cursor.currentList()->format().style() == QTextListFormat::ListDisc) {
|
||||
formats += UnorderedList;
|
||||
}
|
||||
if (cursor.currentList() && cursor.currentList()->format().style() == QTextListFormat::ListDecimal) {
|
||||
formats += OrderedList;
|
||||
}
|
||||
return formats;
|
||||
}
|
||||
131
src/libneochat/enums/richformat.h
Normal file
131
src/libneochat/enums/richformat.h
Normal file
@@ -0,0 +1,131 @@
|
||||
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QTextList>
|
||||
|
||||
class QTextBlockFormat;
|
||||
class QTextCharFormat;
|
||||
class QTextCursor;
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
/**
|
||||
* @class RichFormat
|
||||
*/
|
||||
class RichFormat : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("")
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Enum to define available formats.
|
||||
*
|
||||
* @note The Paragraph and Heading values are intentially fixed to match heading
|
||||
* level values returned by QTextBlockFormat::headingLevel().
|
||||
*
|
||||
* @sa QTextBlockFormat::headingLevel()
|
||||
*/
|
||||
enum Format {
|
||||
Paragraph = 0,
|
||||
Heading1 = 1,
|
||||
Heading2 = 2,
|
||||
Heading3 = 3,
|
||||
Heading4 = 4,
|
||||
Heading5 = 5,
|
||||
Heading6 = 6,
|
||||
Quote = 7,
|
||||
Code = 8,
|
||||
InlineCode,
|
||||
Bold,
|
||||
Italic,
|
||||
UnorderedList,
|
||||
OrderedList,
|
||||
Strikethrough,
|
||||
Underline,
|
||||
};
|
||||
Q_ENUM(Format);
|
||||
|
||||
/**
|
||||
* @brief Enum to define the type of format.
|
||||
*/
|
||||
enum FormatType {
|
||||
Text, /**< The format is applied to the text chars. */
|
||||
List, /**< The format is list style. */
|
||||
Style, /**< The format is a paragraph style. */
|
||||
Block, /**< The format changes the block type. */
|
||||
};
|
||||
Q_ENUM(FormatType);
|
||||
|
||||
/**
|
||||
* @brief Translate the Format enum value to a human readable string.
|
||||
*
|
||||
* @sa Format
|
||||
*/
|
||||
static QString styleString(Format format);
|
||||
|
||||
/**
|
||||
* @brief Return the FormatType for the Format.
|
||||
*
|
||||
* @sa Format, FormatType
|
||||
*/
|
||||
static FormatType typeForFormat(Format format);
|
||||
|
||||
/**
|
||||
* @brief Return the QTextListFormat::Style for the Format.
|
||||
*
|
||||
* @sa Format, QTextListFormat::Style
|
||||
*/
|
||||
static QTextListFormat::Style listStyleForFormat(Format format);
|
||||
|
||||
/**
|
||||
* @brief Return the QTextCharFormat for the Format.
|
||||
*
|
||||
* Inverting returns a format which will remove the format when merged using
|
||||
* QTextCursor::mergeCharFormat().
|
||||
*
|
||||
* @sa Format, QTextCharFormat
|
||||
*/
|
||||
static QTextCharFormat charFormatForFormat(Format format, bool invert = false);
|
||||
|
||||
/**
|
||||
* @brief Return the QTextBlockFormat for the Format.
|
||||
*
|
||||
* @sa Format, QTextBlockFormat
|
||||
*/
|
||||
static QTextBlockFormat blockFormatForFormat(Format format);
|
||||
|
||||
/**
|
||||
* @brief Whether the given QTextCursor has the given Format.
|
||||
*
|
||||
* @sa Format, QTextCursor
|
||||
*/
|
||||
static bool hasFormat(QTextCursor cursor, Format format);
|
||||
|
||||
/**
|
||||
* @brief A lsit of Formats on the given QTextCursor.
|
||||
*
|
||||
* @sa Format, QTextCursor
|
||||
*/
|
||||
static QList<Format> formatsAtCursor(const QTextCursor &cursor);
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Return the heading level for the Format.
|
||||
*
|
||||
* @sa Format
|
||||
*/
|
||||
static int headingLevelForFormat(Format format);
|
||||
|
||||
/**
|
||||
* @brief Return the Format for the heading level.
|
||||
*
|
||||
* @sa Format
|
||||
*/
|
||||
static RichFormat::Format formatForHeadingLevel(int level);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
/**
|
||||
* @class TextStyle
|
||||
*
|
||||
* A class with the Style enum for available text styles.
|
||||
*/
|
||||
class TextStyle : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("")
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Enum to define available styles.
|
||||
*
|
||||
* @note The Paragraph and Heading values are intentially fixed to match heading
|
||||
* level values returned by QTextBlockFormat::headingLevel().
|
||||
*
|
||||
* @sa QTextBlockFormat::headingLevel()
|
||||
*/
|
||||
enum Style {
|
||||
Paragraph = 0,
|
||||
Heading1 = 1,
|
||||
Heading2 = 2,
|
||||
Heading3 = 3,
|
||||
Heading4 = 4,
|
||||
Heading5 = 5,
|
||||
Heading6 = 6,
|
||||
Code = 7,
|
||||
Quote = 8,
|
||||
};
|
||||
Q_ENUM(Style);
|
||||
|
||||
/**
|
||||
* @brief Translate the Kind enum value to a human readable string.
|
||||
*
|
||||
* @sa Kind
|
||||
*/
|
||||
static QString styleString(Style style)
|
||||
{
|
||||
switch (style) {
|
||||
case Style::Paragraph:
|
||||
return u"Paragraph"_s;
|
||||
case Style::Heading1:
|
||||
return u"Heading 1"_s;
|
||||
case Style::Heading2:
|
||||
return u"Heading 2"_s;
|
||||
case Style::Heading3:
|
||||
return u"Heading 3"_s;
|
||||
case Style::Heading4:
|
||||
return u"Heading 4"_s;
|
||||
case Style::Heading5:
|
||||
return u"Heading 5"_s;
|
||||
case Style::Heading6:
|
||||
return u"Heading 6"_s;
|
||||
case Style::Code:
|
||||
return u"Code"_s;
|
||||
case Style::Quote:
|
||||
return u"\"Quote\""_s;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -219,11 +219,16 @@ void NestedListHelper::handleOnIndentLess(const QTextCursor &textCursor)
|
||||
}
|
||||
}
|
||||
|
||||
void NestedListHelper::handleOnBulletType(int styleIndex, const QTextCursor &textCursor)
|
||||
void NestedListHelper::handleOnBulletType(QTextListFormat::Style style, QTextCursor cursor)
|
||||
{
|
||||
QTextCursor cursor = textCursor;
|
||||
if (styleIndex != 0) {
|
||||
auto style = static_cast<QTextListFormat::Style>(styleIndex);
|
||||
if (cursor.isNull()) {
|
||||
return;
|
||||
}
|
||||
QTextListFormat::Style currentListStyle = QTextListFormat::ListStyleUndefined;
|
||||
if (cursor.currentList()) {
|
||||
currentListStyle = cursor.currentList()->format().style();
|
||||
}
|
||||
if (style != currentListStyle && style != QTextListFormat::ListStyleUndefined) {
|
||||
QTextList *currentList = cursor.currentList();
|
||||
QTextListFormat listFmt;
|
||||
|
||||
@@ -245,5 +250,5 @@ void NestedListHelper::handleOnBulletType(int styleIndex, const QTextCursor &tex
|
||||
cursor.setBlockFormat(bfmt);
|
||||
}
|
||||
|
||||
reformatList(textCursor.block());
|
||||
reformatList(cursor.block());
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QTextList>
|
||||
|
||||
class QKeyEvent;
|
||||
class QTextCursor;
|
||||
class QTextBlock;
|
||||
class QTextList;
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -73,7 +74,7 @@ public:
|
||||
*
|
||||
* @param styleIndex The QTextListStyle of the list.
|
||||
*/
|
||||
void handleOnBulletType(int styleIndex, const QTextCursor &textCursor);
|
||||
void handleOnBulletType(QTextListFormat::Style style, QTextCursor cursor);
|
||||
|
||||
/**
|
||||
* @brief Check whether the current item in the list may be indented.
|
||||
|
||||
@@ -90,6 +90,11 @@ TextEdit {
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onTabPressed: (event) => {
|
||||
event.accepted = true;
|
||||
chatDocumentHandler.tab();
|
||||
}
|
||||
|
||||
Keys.onDeletePressed: (event) => {
|
||||
event.accepted = true;
|
||||
chatDocumentHandler.deleteChar();
|
||||
@@ -104,6 +109,15 @@ TextEdit {
|
||||
event.accepted = false;
|
||||
}
|
||||
|
||||
Keys.onEnterPressed: (event) => {
|
||||
event.accepted = true;
|
||||
chatDocumentHandler.insertReturn();
|
||||
}
|
||||
Keys.onReturnPressed: (event) => {
|
||||
event.accepted = true;
|
||||
chatDocumentHandler.insertReturn();
|
||||
}
|
||||
|
||||
onFocusChanged: if (focus && !root.currentFocus) {
|
||||
Message.contentModel.setFocusRow(root.index, true)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include "chatdocumenthandler.h"
|
||||
#include "enums/chatbartype.h"
|
||||
#include "enums/messagecomponenttype.h"
|
||||
#include "enums/textstyle.h"
|
||||
#include "messagecontentmodel.h"
|
||||
|
||||
ChatBarMessageContentModel::ChatBarMessageContentModel(QObject *parent)
|
||||
@@ -278,16 +277,16 @@ ChatBarMessageContentModel::insertComponent(int row, MessageComponentType::Type
|
||||
return it;
|
||||
}
|
||||
|
||||
void ChatBarMessageContentModel::insertStyleAtCursor(TextStyle::Style style)
|
||||
void ChatBarMessageContentModel::insertStyleAtCursor(RichFormat::Format style)
|
||||
{
|
||||
switch (style) {
|
||||
case TextStyle::Paragraph:
|
||||
case RichFormat::Paragraph:
|
||||
insertComponentAtCursor(MessageComponentType::Text);
|
||||
return;
|
||||
case TextStyle::Code:
|
||||
case RichFormat::Code:
|
||||
insertComponentAtCursor(MessageComponentType::Code);
|
||||
return;
|
||||
case TextStyle::Quote:
|
||||
case RichFormat::Quote:
|
||||
insertComponentAtCursor(MessageComponentType::Quote);
|
||||
return;
|
||||
default:
|
||||
@@ -299,7 +298,7 @@ void ChatBarMessageContentModel::insertComponentAtCursor(MessageComponentType::T
|
||||
{
|
||||
if (m_components[m_currentFocusComponent.row()].type == type) {
|
||||
if (type == MessageComponentType::Text && focusedDocumentHandler()) {
|
||||
focusedDocumentHandler()->setStyle(TextStyle::Paragraph);
|
||||
focusedDocumentHandler()->setFormat(RichFormat::Paragraph);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
#include "chatdocumenthandler.h"
|
||||
#include "enums/messagecomponenttype.h"
|
||||
#include "enums/textstyle.h"
|
||||
#include "enums/richformat.h"
|
||||
#include "messagecomponent.h"
|
||||
#include "models/messagecontentmodel.h"
|
||||
|
||||
@@ -56,7 +56,7 @@ public:
|
||||
Q_INVOKABLE void refocusCurrentComponent() const;
|
||||
ChatDocumentHandler *focusedDocumentHandler() const;
|
||||
|
||||
Q_INVOKABLE void insertStyleAtCursor(TextStyle::Style style);
|
||||
Q_INVOKABLE void insertStyleAtCursor(RichFormat::Format style);
|
||||
|
||||
Q_INVOKABLE void insertComponentAtCursor(MessageComponentType::Type type);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user