diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 3adf1af01..a47344b13 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -122,7 +122,7 @@ endmacro() add_executable(qmltest qmltest.cpp chatmarkdownhelpertestwrapper.h - qmltextitemwrappertestwrapper.h + chattextitemhelpertestwrapper.h ) qt_add_qml_module(qmltest URI NeoChatTestUtils) @@ -135,7 +135,6 @@ target_link_libraries(qmltest ) add_qml_tests( - chatdocumenthelpertest.qml + chattextitemhelpertest.qml chatmarkdownhelpertest.qml - qmltextitemwrappertest.qml ) diff --git a/autotests/chatdocumenthelpertest.qml b/autotests/chatdocumenthelpertest.qml deleted file mode 100644 index df077898d..000000000 --- a/autotests/chatdocumenthelpertest.qml +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Carl Schwan -// SPDX-License-Identifier: LGPL-2.0-or-later - -import QtQuick -import QtTest - -import org.kde.neochat.libneochat as LibNeoChat - -TestCase { - name: "ChatDocumentHandlerTest" - - LibNeoChat.ChatDocumentHandler { - id: documentHandler - } - - TextEdit { - id: textEdit - } - - function test_empty(): void { - compare(documentHandler.type, LibNeoChat.ChatBarType.None); - compare(documentHandler.room, null); - compare(documentHandler.textItem, null); - compare(documentHandler.atFirstLine, false); - compare(documentHandler.atLastLine, false); - compare(documentHandler.bold, false); - compare(documentHandler.italic, false); - compare(documentHandler.underline, false); - compare(documentHandler.strikethrough, false); - compare(documentHandler.style, 0); - } -} diff --git a/autotests/chatmarkdownhelpertestwrapper.h b/autotests/chatmarkdownhelpertestwrapper.h index 1dc8a4e57..b16273370 100644 --- a/autotests/chatmarkdownhelpertestwrapper.h +++ b/autotests/chatmarkdownhelpertestwrapper.h @@ -9,8 +9,8 @@ #include #include "chatmarkdownhelper.h" +#include "chattextitemhelper.h" #include "enums/richformat.h" -#include "qmltextitemwrapper.h" class ChatMarkdownHelperTestWrapper : public QObject { @@ -18,7 +18,7 @@ class ChatMarkdownHelperTestWrapper : public QObject QML_ELEMENT /** - * @brief The QML text Item the ChatDocumentHandler is handling. + * @brief The QML text Item the ChatMerkdownHelper is handling. */ Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) @@ -26,7 +26,7 @@ public: explicit ChatMarkdownHelperTestWrapper(QObject *parent = nullptr) : QObject(parent) , m_chatMarkdownHelper(new ChatMarkdownHelper(this)) - , m_textItem(new QmlTextItemWrapper(this)) + , m_textItem(new ChatTextItemHelper(this)) { connect(m_chatMarkdownHelper, &ChatMarkdownHelper::textItemChanged, this, &ChatMarkdownHelperTestWrapper::textItemChanged); connect(m_chatMarkdownHelper, &ChatMarkdownHelper::unhandledBlockFormat, this, &ChatMarkdownHelperTestWrapper::unhandledBlockFormat); @@ -38,7 +38,7 @@ public: } void setTextItem(QQuickItem *textItem) { - auto textItemWrapper = new QmlTextItemWrapper(this); + auto textItemWrapper = new ChatTextItemHelper(this); textItemWrapper->setTextItem(textItem); m_chatMarkdownHelper->setTextItem(textItemWrapper); m_textItem->setTextItem(textItem); @@ -82,5 +82,5 @@ Q_SIGNALS: private: QPointer m_chatMarkdownHelper; - QPointer m_textItem; + QPointer m_textItem; }; diff --git a/autotests/qmltextitemwrappertest.qml b/autotests/chattextitemhelpertest.qml similarity index 71% rename from autotests/qmltextitemwrappertest.qml rename to autotests/chattextitemhelpertest.qml index cd5497bfc..7406cac58 100644 --- a/autotests/qmltextitemwrappertest.qml +++ b/autotests/chattextitemhelpertest.qml @@ -7,7 +7,7 @@ import QtTest import NeoChatTestUtils TestCase { - name: "QmlTextItemWrapperTest" + name: "ChatTextItemHelperTest" TextEdit { id: textEdit @@ -17,51 +17,51 @@ TestCase { id: textEdit2 } - QmlTextItemWrapperTestWrapper { - id: qmlTextItemWrapper + ChatTextItemHelperTestWrapper { + id: chatTextItemHelper textItem: textEdit } SignalSpy { id: spyItem - target: qmlTextItemWrapper + target: chatTextItemHelper signalName: "textItemChanged" } SignalSpy { id: spyContentsChanged - target: qmlTextItemWrapper + target: chatTextItemHelper signalName: "contentsChanged" } SignalSpy { id: spyContentsChange - target: qmlTextItemWrapper + target: chatTextItemHelper signalName: "contentsChange" } SignalSpy { id: spyCursor - target: qmlTextItemWrapper + target: chatTextItemHelper signalName: "cursorPositionChanged" } function test_item(): void { spyItem.clear(); - compare(qmlTextItemWrapper.textItem, textEdit); + compare(chatTextItemHelper.textItem, textEdit); compare(spyItem.count, 0); - qmlTextItemWrapper.textItem = textEdit2; - compare(qmlTextItemWrapper.textItem, textEdit2); + chatTextItemHelper.textItem = textEdit2; + compare(chatTextItemHelper.textItem, textEdit2); compare(spyItem.count, 1); - qmlTextItemWrapper.textItem = textEdit; - compare(qmlTextItemWrapper.textItem, textEdit); + chatTextItemHelper.textItem = textEdit; + compare(chatTextItemHelper.textItem, textEdit); compare(spyItem.count, 2); } function test_document(): void { // We can't get to the QTextDocument from QML so we have to use a helper function. - compare(qmlTextItemWrapper.compareDocuments(textEdit.textDocument), true); + compare(chatTextItemHelper.compareDocuments(textEdit.textDocument), true); } function test_cursor(): void { @@ -69,8 +69,8 @@ TestCase { spyContentsChanged.clear(); spyCursor.clear(); // We can't get to the QTextCursor from QML so we have to use a helper function. - compare(qmlTextItemWrapper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); - compare(textEdit.cursorPosition, qmlTextItemWrapper.cursorPosition()); + compare(chatTextItemHelper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); + compare(textEdit.cursorPosition, chatTextItemHelper.cursorPosition()); textEdit.insert(0, "test text") compare(spyContentsChange.count, 1); compare(spyContentsChange.signalArguments[0][0], 0); @@ -78,13 +78,13 @@ TestCase { compare(spyContentsChange.signalArguments[0][2], 9); compare(spyContentsChanged.count, 1); compare(spyCursor.count, 1); - compare(qmlTextItemWrapper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); - compare(textEdit.cursorPosition, qmlTextItemWrapper.cursorPosition()); + compare(chatTextItemHelper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); + compare(textEdit.cursorPosition, chatTextItemHelper.cursorPosition()); textEdit.selectAll(); compare(spyContentsChanged.count, 1); compare(spyCursor.count, 1); - compare(qmlTextItemWrapper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); - compare(textEdit.cursorPosition, qmlTextItemWrapper.cursorPosition()); + compare(chatTextItemHelper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); + compare(textEdit.cursorPosition, chatTextItemHelper.cursorPosition()); textEdit.clear(); compare(spyContentsChange.count, 2); compare(spyContentsChange.signalArguments[1][0], 0); @@ -100,18 +100,18 @@ TestCase { textEdit.insert(0, "test text"); compare(textEdit.cursorPosition, 9); compare(spyCursor.count, 1); - qmlTextItemWrapper.setCursorPosition(5); + chatTextItemHelper.setCursorPosition(5); compare(textEdit.cursorPosition, 5); compare(spyCursor.count, 2); - qmlTextItemWrapper.setCursorPosition(1); + chatTextItemHelper.setCursorPosition(1); compare(textEdit.cursorPosition, 1); compare(spyCursor.count, 3); textEdit.cursorVisible = false; compare(textEdit.cursorVisible, false); - qmlTextItemWrapper.setCursorVisible(true); + chatTextItemHelper.setCursorVisible(true); compare(textEdit.cursorVisible, true); - qmlTextItemWrapper.setCursorVisible(false); + chatTextItemHelper.setCursorVisible(false); compare(textEdit.cursorVisible, false); textEdit.clear(); @@ -121,7 +121,7 @@ TestCase { function test_forceActiveFocus(): void { textEdit2.forceActiveFocus(); compare(textEdit.activeFocus, false); - qmlTextItemWrapper.forceActiveFocus(); + chatTextItemHelper.forceActiveFocus(); compare(textEdit.activeFocus, true); } } diff --git a/autotests/qmltextitemwrappertestwrapper.h b/autotests/chattextitemhelpertestwrapper.h similarity index 70% rename from autotests/qmltextitemwrappertestwrapper.h rename to autotests/chattextitemhelpertestwrapper.h index 216a429ec..a0c5ea3d3 100644 --- a/autotests/qmltextitemwrappertestwrapper.h +++ b/autotests/chattextitemhelpertestwrapper.h @@ -8,28 +8,28 @@ #include #include -#include "qmltextitemwrapper.h" +#include "chattextitemhelper.h" -class QmlTextItemWrapperTestWrapper : public QObject +class ChatTextItemHelperTestWrapper : public QObject { Q_OBJECT QML_ELEMENT /** - * @brief The QML text Item the ChatDocumentHandler is handling. + * @brief The QML text Item the TextItemHelper is handling. */ Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) public: - explicit QmlTextItemWrapperTestWrapper(QObject *parent = nullptr) + explicit ChatTextItemHelperTestWrapper(QObject *parent = nullptr) : QObject(parent) - , m_textItemWrapper(new QmlTextItemWrapper(this)) + , m_textItemWrapper(new ChatTextItemHelper(this)) { Q_ASSERT(m_textItemWrapper); - connect(m_textItemWrapper, &QmlTextItemWrapper::textItemChanged, this, &QmlTextItemWrapperTestWrapper::textItemChanged); - connect(m_textItemWrapper, &QmlTextItemWrapper::contentsChange, this, &QmlTextItemWrapperTestWrapper::contentsChange); - connect(m_textItemWrapper, &QmlTextItemWrapper::contentsChanged, this, &QmlTextItemWrapperTestWrapper::contentsChanged); - connect(m_textItemWrapper, &QmlTextItemWrapper::cursorPositionChanged, this, &QmlTextItemWrapperTestWrapper::cursorPositionChanged); + connect(m_textItemWrapper, &ChatTextItemHelper::textItemChanged, this, &ChatTextItemHelperTestWrapper::textItemChanged); + connect(m_textItemWrapper, &ChatTextItemHelper::contentsChange, this, &ChatTextItemHelperTestWrapper::contentsChange); + connect(m_textItemWrapper, &ChatTextItemHelper::contentsChanged, this, &ChatTextItemHelperTestWrapper::contentsChanged); + connect(m_textItemWrapper, &ChatTextItemHelper::cursorPositionChanged, this, &ChatTextItemHelperTestWrapper::cursorPositionChanged); } QQuickItem *textItem() const @@ -85,5 +85,5 @@ Q_SIGNALS: void cursorPositionChanged(); private: - QPointer m_textItemWrapper; + QPointer m_textItemWrapper; }; diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index d6fe5ee88..e249f66b0 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -149,7 +149,9 @@ QQC2.Control { CompletionMenu { id: completionMenu - chatDocumentHandler: contentModel.focusedDocumentHandler + room: root.currentRoom + type: LibNeoChat.ChatBarType.Room + textItem: chatContentView.model.focusedTextItem x: 1 y: -height diff --git a/src/chatbar/CompletionMenu.qml b/src/chatbar/CompletionMenu.qml index 574714eb5..f1d97e58d 100644 --- a/src/chatbar/CompletionMenu.qml +++ b/src/chatbar/CompletionMenu.qml @@ -12,11 +12,25 @@ import org.kde.kirigamiaddons.delegates as Delegates import org.kde.kirigamiaddons.labs.components as KirigamiComponents import org.kde.neochat +import org.kde.neochat.libneochat as LibNeoChat QQC2.Popup { id: root - required property var chatDocumentHandler + /** + * @brief The current room that user is viewing. + */ + required property LibNeoChat.NeoChatRoom room + + /** + * @brief The chatbar type + */ + required property int type + + /** + * @brief The chatbar type + */ + required property LibNeoChat.ChatTextItemHelper textItem visible: completions.count > 0 @@ -33,7 +47,7 @@ QQC2.Popup { } function complete() { - root.chatDocumentHandler.insertCompletion(completions.currentItem.replacedText, completions.currentItem.hRef) + completionModel.insertCompletion(completions.currentItem.replacedText, completions.currentItem.hRef) } leftPadding: 0 @@ -60,8 +74,11 @@ QQC2.Popup { ListView { id: completions - model: CompletionModel { - textItem: root.chatDocumentHandler.textItem + model: LibNeoChat.CompletionModel { + id: completionModel + room: root.room + type: root.type + textItem: root.textItem roomListModel: RoomManager.roomListModel userListModel: RoomManager.userListModel } @@ -97,7 +114,7 @@ QQC2.Popup { subtitleItem.textFormat: Text.PlainText } } - onClicked: root.chatDocumentHandler.insertCompletion(replacedText, hRef) + onClicked: completionModel.insertCompletion(replacedText, hRef) } } } diff --git a/src/chatbar/RichEditBar.qml b/src/chatbar/RichEditBar.qml index ef4734f51..bb112ef1a 100644 --- a/src/chatbar/RichEditBar.qml +++ b/src/chatbar/RichEditBar.qml @@ -22,7 +22,6 @@ QQC2.ToolBar { property LibNeoChat.ChatBarCache chatBarCache required property MessageContent.ChatBarMessageContentModel contentModel - readonly property LibNeoChat.ChatDocumentHandler focusedDocumentHandler: contentModel.focusedDocumentHandler Connections { target: contentModel @@ -65,7 +64,7 @@ QQC2.ToolBar { 3 readonly property ChatButtonHelper chatButtonHelper: ChatButtonHelper { - textItem: contentModel.currentTextItem + textItem: contentModel.focusedTextItem } signal clicked diff --git a/src/chatbar/StylePicker.qml b/src/chatbar/StylePicker.qml index 50ef95d93..9b9ea188f 100644 --- a/src/chatbar/StylePicker.qml +++ b/src/chatbar/StylePicker.qml @@ -17,7 +17,6 @@ QQC2.Popup { required property MessageContent.ChatBarMessageContentModel chatContentModel required property ChatButtonHelper chatButtonHelper - readonly property LibNeoChat.ChatDocumentHandler focusedDocumentHandler: chatContentModel.focusedDocumentHandler y: -implicitHeight @@ -88,7 +87,7 @@ QQC2.Popup { radius: Kirigami.Units.cornerRadius border { width: 1 - color: styleDelegate.hovered || (root.focusedDocumentHandler?.style ?? false) === styleDelegate.index ? + color: styleDelegate.hovered || root.chatButtonHelper.currentStyle === styleDelegate.index ? Kirigami.Theme.highlightColor : Kirigami.ColorUtils.linearInterpolation( Kirigami.Theme.backgroundColor, diff --git a/src/chatbar/chatbuttonhelper.cpp b/src/chatbar/chatbuttonhelper.cpp index 1a64a20dd..347fc38ba 100644 --- a/src/chatbar/chatbuttonhelper.cpp +++ b/src/chatbar/chatbuttonhelper.cpp @@ -12,12 +12,12 @@ ChatButtonHelper::ChatButtonHelper(QObject *parent) { } -QmlTextItemWrapper *ChatButtonHelper::textItem() const +ChatTextItemHelper *ChatButtonHelper::textItem() const { return m_textItem; } -void ChatButtonHelper::setTextItem(QmlTextItemWrapper *textItem) +void ChatButtonHelper::setTextItem(ChatTextItemHelper *textItem) { if (textItem == m_textItem) { return; @@ -30,11 +30,10 @@ void ChatButtonHelper::setTextItem(QmlTextItemWrapper *textItem) m_textItem = textItem; if (m_textItem) { - connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, &ChatButtonHelper::textItemChanged); - connect(m_textItem, &QmlTextItemWrapper::formatChanged, this, &ChatButtonHelper::linkChanged); - connect(m_textItem, &QmlTextItemWrapper::textFormatChanged, this, &ChatButtonHelper::textFormatChanged); - connect(m_textItem, &QmlTextItemWrapper::styleChanged, this, &ChatButtonHelper::styleChanged); - connect(m_textItem, &QmlTextItemWrapper::listChanged, this, &ChatButtonHelper::listChanged); + connect(m_textItem, &ChatTextItemHelper::formatChanged, this, &ChatButtonHelper::linkChanged); + connect(m_textItem, &ChatTextItemHelper::textFormatChanged, this, &ChatButtonHelper::textFormatChanged); + connect(m_textItem, &ChatTextItemHelper::styleChanged, this, &ChatButtonHelper::styleChanged); + connect(m_textItem, &ChatTextItemHelper::listChanged, this, &ChatButtonHelper::listChanged); } Q_EMIT textItemChanged(); @@ -88,6 +87,14 @@ bool ChatButtonHelper::orderedList() const return m_textItem->formatsAtCursor().contains(RichFormat::OrderedList); } +RichFormat::Format ChatButtonHelper::currentStyle() const +{ + if (!m_textItem) { + return RichFormat::Paragraph; + } + return static_cast(m_textItem->textCursor().blockFormat().headingLevel()); +} + void ChatButtonHelper::setFormat(RichFormat::Format format) { if (!m_textItem) { diff --git a/src/chatbar/chatbuttonhelper.h b/src/chatbar/chatbuttonhelper.h index d024e3f73..b12be3299 100644 --- a/src/chatbar/chatbuttonhelper.h +++ b/src/chatbar/chatbuttonhelper.h @@ -6,7 +6,7 @@ #include #include -#include "qmltextitemwrapper.h" +#include "chattextitemhelper.h" class ChatButtonHelper : public QObject { @@ -16,12 +16,12 @@ class ChatButtonHelper : public QObject /** * @brief The text item that the helper is interfacing with. * - * This is a QQuickItem that is a TextEdit (or inherited from) wrapped in a QmlTextItemWrapper + * This is a QQuickItem that is a TextEdit (or inherited from) wrapped in a ChatTextItemHelper * to provide easy access to properties and basic QTextDocument manipulation. * - * @sa TextEdit, QTextDocument, QmlTextItemWrapper + * @sa TextEdit, QTextDocument, ChatTextItemHelper */ - Q_PROPERTY(QmlTextItemWrapper *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) + Q_PROPERTY(ChatTextItemHelper *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) /** * @brief Whether the text format at the current cursor is bold. @@ -53,6 +53,11 @@ class ChatButtonHelper : public QObject */ Q_PROPERTY(bool orderedList READ orderedList NOTIFY listChanged) + /** + * @brief The current style at the cursor. + */ + Q_PROPERTY(RichFormat::Format currentStyle READ currentStyle NOTIFY styleChanged) + /** * @brief Whether the list at the current cursor can be indented one level more. */ @@ -76,8 +81,8 @@ class ChatButtonHelper : public QObject public: explicit ChatButtonHelper(QObject *parent = nullptr); - QmlTextItemWrapper *textItem() const; - void setTextItem(QmlTextItemWrapper *textItem); + ChatTextItemHelper *textItem() const; + void setTextItem(ChatTextItemHelper *textItem); bool bold() const; bool italic() const; @@ -85,6 +90,7 @@ public: bool strikethrough() const; bool unorderedList() const; bool orderedList() const; + RichFormat::Format currentStyle() const; /** * @brief Apply the given format at the current cursor position. @@ -129,7 +135,7 @@ Q_SIGNALS: void linkChanged(); private: - QPointer m_textItem; + QPointer m_textItem; void selectLinkText(QTextCursor &cursor) const; }; diff --git a/src/chatbar/styledelegatehelper.h b/src/chatbar/styledelegatehelper.h index acd5f534d..caf808c8b 100644 --- a/src/chatbar/styledelegatehelper.h +++ b/src/chatbar/styledelegatehelper.h @@ -15,7 +15,7 @@ class StyleDelegateHelper : public QObject QML_ELEMENT /** - * @brief The QML text Item the ChatDocumentHandler is handling. + * @brief The QML text Item the StyleDelegateHelper is handling. */ Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) diff --git a/src/libneochat/CMakeLists.txt b/src/libneochat/CMakeLists.txt index cd37658f3..fac280dfd 100644 --- a/src/libneochat/CMakeLists.txt +++ b/src/libneochat/CMakeLists.txt @@ -9,8 +9,10 @@ target_sources(LibNeoChat PRIVATE neochatroommember.cpp accountmanager.cpp chatbarcache.cpp - chatdocumenthandler.cpp + chatbarsyntaxhighlighter.cpp + chatkeyhelper.cpp chatmarkdownhelper.cpp + chattextitemhelper.cpp clipboard.cpp delegatesizehelper.cpp emojitones.cpp @@ -21,7 +23,6 @@ target_sources(LibNeoChat PRIVATE neochatdatetime.cpp nestedlisthelper_p.h nestedlisthelper.cpp - qmltextitemwrapper.cpp roomlastmessageprovider.cpp spacehierarchycache.cpp texthandler.cpp @@ -92,13 +93,6 @@ ecm_qt_declare_logging_category(LibNeoChat DEFAULT_SEVERITY Info ) -ecm_qt_declare_logging_category(LibNeoChat - HEADER "chatdocumenthandler_logging.h" - IDENTIFIER "ChatDocumentHandling" - CATEGORY_NAME "org.kde.neochat.chatdocumenthandler" - DEFAULT_SEVERITY Info -) - generate_export_header(LibNeoChat BASE_NAME LibNeoChat) target_include_directories(LibNeoChat PRIVATE ${CMAKE_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/enums ${CMAKE_CURRENT_SOURCE_DIR}/events ${CMAKE_CURRENT_SOURCE_DIR}/models) target_link_libraries(LibNeoChat PUBLIC diff --git a/src/libneochat/chatbarsyntaxhighlighter.cpp b/src/libneochat/chatbarsyntaxhighlighter.cpp new file mode 100644 index 000000000..0933a7254 --- /dev/null +++ b/src/libneochat/chatbarsyntaxhighlighter.cpp @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2020 Carl Schwan +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "chatbarsyntaxhighlighter.h" + +#include "chatbarcache.h" +#include "chattextitemhelper.h" +#include "enums/chatbartype.h" + +ChatBarSyntaxHighlighter::ChatBarSyntaxHighlighter(QObject *parent) + : QSyntaxHighlighter(parent) +{ + m_theme = static_cast(qmlAttachedPropertiesObject(this, true)); + connect(m_theme, &Kirigami::Platform::PlatformTheme::colorsChanged, this, [this]() { + m_mentionFormat.setForeground(m_theme->linkColor()); + m_errorFormat.setForeground(m_theme->negativeTextColor()); + }); + + m_mentionFormat.setFontWeight(QFont::Bold); + m_mentionFormat.setForeground(m_theme->linkColor()); + + m_errorFormat.setForeground(m_theme->negativeTextColor()); + m_errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); + + connect(m_checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) { + m_errors += {start, word}; + m_checker->continueChecking(); + }); + connect(m_checker, &Sonnet::BackgroundChecker::done, this, [this]() { + m_rehighlightTimer.start(); + }); + m_rehighlightTimer.setInterval(100); + m_rehighlightTimer.setSingleShot(true); + m_rehighlightTimer.callOnTimeout(this, &QSyntaxHighlighter::rehighlight); +} + +void ChatBarSyntaxHighlighter::highlightBlock(const QString &text) +{ + if (m_settings.checkerEnabledByDefault()) { + if (text != m_previousText) { + m_previousText = text; + m_checker->stop(); + m_errors.clear(); + m_checker->setText(text); + } + for (const auto &error : m_errors) { + setFormat(error.first, error.second.size(), m_errorFormat); + } + } + + if (!room || type == ChatBarType::None) { + return; + } + auto mentions = room->cacheForType(type)->mentions(); + mentions->erase(std::remove_if(mentions->begin(), + mentions->end(), + [this](auto &mention) { + if (document()->toPlainText().isEmpty()) { + return false; + } + + if (mention.cursor.position() == 0 && mention.cursor.anchor() == 0) { + return true; + } + + if (mention.cursor.position() - mention.cursor.anchor() != mention.text.size()) { + mention.cursor.setPosition(mention.start); + mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor); + } + + if (mention.cursor.selectedText() != mention.text) { + return true; + } + if (currentBlock() == mention.cursor.block()) { + mention.start = mention.cursor.anchor(); + mention.position = mention.cursor.position(); + setFormat(mention.cursor.selectionStart(), mention.cursor.selectedText().size(), m_mentionFormat); + } + return false; + }), + mentions->end()); +} diff --git a/src/libneochat/chatbarsyntaxhighlighter.h b/src/libneochat/chatbarsyntaxhighlighter.h new file mode 100644 index 000000000..630e63a6a --- /dev/null +++ b/src/libneochat/chatbarsyntaxhighlighter.h @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2020 Carl Schwan +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +#include + +#include +#include + +#include "chattextitemhelper.h" +#include "neochatroom.h" + +class ChatBarSyntaxHighlighter : public QSyntaxHighlighter +{ + Q_OBJECT + +public: + explicit ChatBarSyntaxHighlighter(QObject *parent = nullptr); + + QPointer room; + ChatBarType::Type type = ChatBarType::None; + + ChatTextItemHelper *textItem() const; + void setTextItem(ChatTextItemHelper *textItem); + + void highlightBlock(const QString &text) override; + +private: + Kirigami::Platform::PlatformTheme *m_theme = nullptr; + QTextCharFormat m_mentionFormat; + QTextCharFormat m_errorFormat; + + Sonnet::BackgroundChecker *m_checker = new Sonnet::BackgroundChecker; + Sonnet::Settings m_settings; + QString m_previousText; + + QList> m_errors; + QTimer m_rehighlightTimer; +}; diff --git a/src/libneochat/chatdocumenthandler.cpp b/src/libneochat/chatdocumenthandler.cpp deleted file mode 100644 index fa4eb7beb..000000000 --- a/src/libneochat/chatdocumenthandler.cpp +++ /dev/null @@ -1,654 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Carl Schwan -// SPDX-FileCopyrightText: 2025 James Graham -// SPDX-License-Identifier: GPL-3.0-or-later - -#include "chatdocumenthandler.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "chatbartype.h" -#include "chatdocumenthandler_logging.h" -#include "chatmarkdownhelper.h" -#include "eventhandler.h" -#include "qmltextitemwrapper.h" - -using namespace Qt::StringLiterals; - -class SyntaxHighlighter : public QSyntaxHighlighter -{ -public: - QPointer room; - QTextCharFormat mentionFormat; - QTextCharFormat errorFormat; - Sonnet::BackgroundChecker checker; - Sonnet::Settings settings; - QList> errors; - QString previousText; - QTimer rehighlightTimer; - SyntaxHighlighter(QObject *parent) - : QSyntaxHighlighter(parent) - { - m_theme = static_cast(qmlAttachedPropertiesObject(this, true)); - connect(m_theme, &Kirigami::Platform::PlatformTheme::colorsChanged, this, [this]() { - mentionFormat.setForeground(m_theme->linkColor()); - errorFormat.setForeground(m_theme->negativeTextColor()); - }); - - mentionFormat.setFontWeight(QFont::Bold); - mentionFormat.setForeground(m_theme->linkColor()); - - errorFormat.setForeground(m_theme->negativeTextColor()); - errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); - - connect(&checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) { - errors += {start, word}; - checker.continueChecking(); - }); - connect(&checker, &Sonnet::BackgroundChecker::done, this, [this]() { - rehighlightTimer.start(); - }); - rehighlightTimer.setInterval(100); - rehighlightTimer.setSingleShot(true); - rehighlightTimer.callOnTimeout(this, &QSyntaxHighlighter::rehighlight); - } - void highlightBlock(const QString &text) override - { - if (settings.checkerEnabledByDefault()) { - if (text != previousText) { - previousText = text; - checker.stop(); - errors.clear(); - checker.setText(text); - } - for (const auto &error : errors) { - setFormat(error.first, error.second.size(), errorFormat); - } - } - - auto handler = dynamic_cast(parent()); - auto room = handler->room(); - if (!room) { - return; - } - if (!room) { - return; - } - auto mentions = room->cacheForType(handler->type())->mentions(); - mentions->erase(std::remove_if(mentions->begin(), - mentions->end(), - [this](auto &mention) { - if (document()->toPlainText().isEmpty()) { - return false; - } - - if (mention.cursor.position() == 0 && mention.cursor.anchor() == 0) { - return true; - } - - if (mention.cursor.position() - mention.cursor.anchor() != mention.text.size()) { - mention.cursor.setPosition(mention.start); - mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor); - } - - if (mention.cursor.selectedText() != mention.text) { - return true; - } - if (currentBlock() == mention.cursor.block()) { - mention.start = mention.cursor.anchor(); - mention.position = mention.cursor.position(); - setFormat(mention.cursor.selectionStart(), mention.cursor.selectedText().size(), mentionFormat); - } - return false; - }), - mentions->end()); - } - -private: - Kirigami::Platform::PlatformTheme *m_theme = nullptr; -}; - -ChatDocumentHandler::ChatDocumentHandler(QObject *parent) - : QObject(parent) - , m_textItem(new QmlTextItemWrapper(this)) - , m_highlighter(new SyntaxHighlighter(this)) -{ - connectTextItem(); -} - -ChatBarType::Type ChatDocumentHandler::type() const -{ - return m_type; -} - -void ChatDocumentHandler::setType(ChatBarType::Type type) -{ - if (type == m_type) { - return; - } - m_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; - Q_EMIT roomChanged(); -} - -QQuickItem *ChatDocumentHandler::textItem() const -{ - return m_textItem->textItem(); -} - -void ChatDocumentHandler::setTextItem(QQuickItem *textItem) -{ - m_textItem->setTextItem(textItem); -} - -void ChatDocumentHandler::connectTextItem() -{ - Q_ASSERT(m_textItem); - connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, [this]() { - m_highlighter->setDocument(m_textItem->document()); - initializeChars(); - }); - connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, &ChatDocumentHandler::textItemChanged); - connect(m_textItem, &QmlTextItemWrapper::contentsChanged, this, &ChatDocumentHandler::contentsChanged); - connect(m_textItem, &QmlTextItemWrapper::contentsChanged, this, &ChatDocumentHandler::atFirstLineChanged); - connect(m_textItem, &QmlTextItemWrapper::contentsChanged, this, &ChatDocumentHandler::atLastLineChanged); - connect(m_textItem, &QmlTextItemWrapper::cursorPositionChanged, this, &ChatDocumentHandler::atFirstLineChanged); - connect(m_textItem, &QmlTextItemWrapper::cursorPositionChanged, this, &ChatDocumentHandler::atLastLineChanged); - connect(m_textItem, &QmlTextItemWrapper::formatChanged, this, &ChatDocumentHandler::formatChanged); - connect(m_textItem, &QmlTextItemWrapper::textFormatChanged, this, &ChatDocumentHandler::textFormatChanged); - connect(m_textItem, &QmlTextItemWrapper::styleChanged, this, &ChatDocumentHandler::styleChanged); -} - -ChatDocumentHandler *ChatDocumentHandler::previousDocumentHandler() const -{ - return m_previousDocumentHandler; -} - -void ChatDocumentHandler::setPreviousDocumentHandler(ChatDocumentHandler *previousDocumentHandler) -{ - m_previousDocumentHandler = previousDocumentHandler; -} - -ChatDocumentHandler *ChatDocumentHandler::nextDocumentHandler() const -{ - return m_nextDocumentHandler; -} - -void ChatDocumentHandler::setNextDocumentHandler(ChatDocumentHandler *nextDocumentHandler) -{ - m_nextDocumentHandler = nextDocumentHandler; -} - -QString ChatDocumentHandler::fixedStartChars() const -{ - return m_fixedStartChars; -} - -void ChatDocumentHandler::setFixedStartChars(const QString &chars) -{ - if (chars == m_fixedStartChars) { - return; - } - m_fixedStartChars = chars; -} - -QString ChatDocumentHandler::fixedEndChars() const -{ - return m_fixedEndChars; - ; -} - -void ChatDocumentHandler::setFixedEndChars(const QString &chars) -{ - if (chars == m_fixedEndChars) { - return; - } - m_fixedEndChars = chars; -} - -QString ChatDocumentHandler::initialText() const -{ - return m_initialText; -} - -void ChatDocumentHandler::setInitialText(const QString &text) -{ - if (text == m_initialText) { - return; - } - m_initialText = text; -} - -void ChatDocumentHandler::initializeChars() -{ - const auto doc = m_textItem->document(); - if (!doc) { - return; - } - - QTextCursor cursor = QTextCursor(doc); - if (cursor.isNull()) { - return; - } - - if (doc->isEmpty() && !m_initialText.isEmpty()) { - cursor.insertText(m_initialText); - } - - if (!m_fixedStartChars.isEmpty() && doc->characterAt(0) != m_fixedStartChars) { - cursor.movePosition(QTextCursor::Start); - cursor.insertText(m_fixedEndChars); - } - - if (!m_fixedStartChars.isEmpty() && doc->characterAt(doc->characterCount()) != m_fixedStartChars) { - cursor.movePosition(QTextCursor::End); - cursor.insertText(m_fixedEndChars); - } -} - -bool ChatDocumentHandler::isEmpty() const -{ - return htmlText().length() == 0; -} - -bool ChatDocumentHandler::atFirstLine() const -{ - const auto cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return false; - } - return cursor.blockNumber() == 0 && cursor.block().layout()->lineForTextPosition(cursor.positionInBlock()).lineNumber() == 0; -} - -bool ChatDocumentHandler::atLastLine() const -{ - const auto cursor = m_textItem->textCursor(); - const auto doc = m_textItem->document(); - if (cursor.isNull() || !doc) { - return false; - } - return cursor.blockNumber() == doc->blockCount() - 1 - && cursor.block().layout()->lineForTextPosition(cursor.positionInBlock()).lineNumber() == (cursor.block().layout()->lineCount() - 1); -} - -void ChatDocumentHandler::setCursorFromDocumentHandler(ChatDocumentHandler *previousDocumentHandler, bool infront, int defaultPosition) -{ - const auto doc = m_textItem->document(); - if (!doc) { - return; - } - - m_textItem->forceActiveFocus(); - - if (!previousDocumentHandler) { - const auto docLastBlockLayout = doc->lastBlock().layout(); - m_textItem->setCursorPosition(infront ? defaultPosition : docLastBlockLayout->lineAt(docLastBlockLayout->lineCount() - 1).textStart()); - m_textItem->setCursorVisible(true); - return; - } - - const auto previousLinePosition = previousDocumentHandler->cursorPositionInLine(); - const auto newMaxLineLength = lineLength(infront ? 0 : lineCount() - 1); - m_textItem->setCursorPosition(std::min(previousLinePosition, newMaxLineLength ? *newMaxLineLength : defaultPosition) - + (infront ? 0 : doc->lastBlock().position())); - m_textItem->setCursorVisible(true); -} - -int ChatDocumentHandler::lineCount() const -{ - if (const auto doc = m_textItem->document()) { - return doc->lineCount(); - } - return 0; -} - -std::optional ChatDocumentHandler::lineLength(int lineNumber) const -{ - const auto doc = m_textItem->document(); - if (!doc || lineNumber < 0 || lineNumber >= doc->lineCount()) { - return std::nullopt; - } - const auto block = doc->findBlockByLineNumber(lineNumber); - const auto lineNumInBlock = lineNumber - block.firstLineNumber(); - return block.layout()->lineAt(lineNumInBlock).textLength(); -} - -int ChatDocumentHandler::cursorPositionInLine() const -{ - const auto cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return false; - } - return cursor.positionInBlock(); -} - -QTextDocumentFragment ChatDocumentHandler::takeFirstBlock() -{ - auto cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return {}; - } - cursor.beginEditBlock(); - cursor.movePosition(QTextCursor::Start); - cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor, m_fixedStartChars.length()); - cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); - if (m_textItem->document()->blockCount() <= 1) { - cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor, m_fixedEndChars.length()); - } - - const auto block = cursor.selection(); - cursor.removeSelectedText(); - cursor.endEditBlock(); - if (m_textItem->document()->characterCount() - 1 <= (m_fixedStartChars.length() + m_fixedEndChars.length())) { - Q_EMIT removeMe(this); - } - return block; -} - -void ChatDocumentHandler::fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional &afterFragment) -{ - auto cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - - if (cursor.blockNumber() > 0) { - hasBefore = true; - } - auto afterBlock = cursor.blockNumber() < m_textItem->document()->blockCount() - 1; - - cursor.beginEditBlock(); - cursor.movePosition(QTextCursor::StartOfBlock); - if (!hasBefore) { - cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor, m_fixedStartChars.length()); - } - cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); - if (!afterBlock) { - cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor, m_fixedEndChars.length()); - } - cursor.endEditBlock(); - - midFragment = cursor.selection(); - if (!midFragment.isEmpty()) { - cursor.removeSelectedText(); - } - cursor.deletePreviousChar(); - if (afterBlock) { - cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor); - afterFragment = cursor.selection(); - cursor.removeSelectedText(); - } -} - -void ChatDocumentHandler::insertFragment(const QTextDocumentFragment fragment, InsertPosition position, bool keepPosition) -{ - auto cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - - int currentPosition; - switch (position) { - case Start: - currentPosition = 0; - break; - case End: - currentPosition = m_textItem->document()->characterCount() - 1; - break; - case Cursor: - currentPosition = cursor.position(); - break; - } - cursor.setPosition(currentPosition); - if (textFormat() && textFormat() == Qt::PlainText) { - const auto wasEmpty = isEmpty(); - auto text = fragment.toPlainText(); - while (text.startsWith(u"\n"_s)) { - text.removeFirst(); - } - while (text.endsWith(u"\n"_s)) { - text.removeLast(); - } - cursor.insertText(fragment.toPlainText()); - if (wasEmpty) { - cursor.movePosition(QTextCursor::StartOfBlock); - cursor.deletePreviousChar(); - cursor.movePosition(QTextCursor::EndOfBlock); - cursor.deleteChar(); - } - } else { - cursor.insertMarkdown(trim(fragment.toMarkdown())); - } - if (keepPosition) { - cursor.setPosition(currentPosition); - } - m_textItem->setCursorPosition(cursor.position()); -} - -QString ChatDocumentHandler::getText() const -{ - if (!m_textItem->document()) { - qCWarning(ChatDocumentHandling) << "getText called with no QQuickTextDocument available."; - return {}; - } - return m_textItem->document()->toPlainText(); -} - -void ChatDocumentHandler::pushMention(const Mention mention) const -{ - if (!m_room || m_type == ChatBarType::None) { - qCWarning(ChatDocumentHandling) << "pushMention called with no ChatBarCache available. ChatBarType: " << m_type << " Room: " << m_room; - return; - } - m_room->cacheForType(m_type)->mentions()->push_back(mention); -} - -void ChatDocumentHandler::updateMentions(const QString &editId) -{ - if (editId.isEmpty() || m_type == ChatBarType::None || !m_room) { - return; - } - - if (auto event = m_room->findInTimeline(editId); event != m_room->historyEdge()) { - if (const auto &roomMessageEvent = &*event->viewAs()) { - // Replaces the mentions that are baked into the HTML but plaintext in the original markdown - const QRegularExpression re(uR"lit(([\S]*)<\/a>)lit"_s); - - m_room->cacheForType(m_type)->mentions()->clear(); - - int linkSize = 0; - auto matches = re.globalMatch(EventHandler::rawMessageBody(*roomMessageEvent)); - while (matches.hasNext()) { - const QRegularExpressionMatch match = matches.next(); - if (match.hasMatch()) { - const QString id = match.captured(1); - const QString name = match.captured(2); - - const int position = match.capturedStart(0) - linkSize; - const int end = position + name.length(); - linkSize += match.capturedLength(0) - name.length(); - - QTextCursor cursor(m_textItem->document()); - cursor.setPosition(position); - cursor.setPosition(end, QTextCursor::KeepAnchor); - cursor.setKeepPositionOnInsert(true); - - pushMention(Mention{.cursor = cursor, .text = name, .start = position, .position = end, .id = id}); - } - } - } - } -} - -std::optional ChatDocumentHandler::textFormat() const -{ - if (!m_textItem) { - return std::nullopt; - } - - return static_cast(m_textItem->property("textFormat").toInt()); -} - -void ChatDocumentHandler::mergeFormatOnWordOrSelection(const QTextCharFormat &format) -{ - QTextCursor cursor = m_textItem->textCursor(); - if (!cursor.hasSelection()) { - cursor.select(QTextCursor::WordUnderCursor); - } - if (cursor.hasSelection()) { - cursor.mergeCharFormat(format); - } -} - -void ChatDocumentHandler::insertCompletion(const QString &text, const QUrl &link) -{ - QTextCursor cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - - cursor.beginEditBlock(); - while (!cursor.selectedText().startsWith(u' ') && !cursor.atBlockStart()) { - cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); - } - if (cursor.selectedText().startsWith(u' ')) { - cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); - } - cursor.removeSelectedText(); - - const int start = cursor.position(); - const auto insertString = u"%1 %2"_s.arg(text, link.isEmpty() ? QString() : u" "_s); - cursor.insertText(insertString); - cursor.setPosition(start); - cursor.setPosition(start + text.size(), QTextCursor::KeepAnchor); - cursor.setKeepPositionOnInsert(true); - cursor.endEditBlock(); - if (!link.isEmpty()) { - pushMention({ - .cursor = cursor, - .text = text, - .id = link.toString(), - }); - } - m_highlighter->rehighlight(); -} - -void ChatDocumentHandler::tab() -{ - QTextCursor cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - if (cursor.currentList()) { - m_textItem->indentListMoreAtCursor(); - return; - } - cursor.insertText(u" "_s); -} - -void ChatDocumentHandler::deleteChar() -{ - QTextCursor cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - if (cursor.position() >= m_textItem->document()->characterCount() - m_fixedEndChars.length() - 1) { - if (const auto nextHandler = nextDocumentHandler()) { - insertFragment(nextHandler->takeFirstBlock(), Cursor, true); - } - return; - } - cursor.deleteChar(); -} - -void ChatDocumentHandler::backspace() -{ - QTextCursor cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - if (cursor.position() <= m_fixedStartChars.length()) { - if (cursor.currentList()) { - m_textItem->indentListLessAtCursor(); - return; - } - if (const auto previousHandler = previousDocumentHandler()) { - previousHandler->insertFragment(takeFirstBlock(), End, true); - } else { - Q_EMIT unhandledBackspaceAtBeginning(this); - } - return; - } - cursor.deletePreviousChar(); -} - -void ChatDocumentHandler::insertReturn() -{ - QTextCursor cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - cursor.insertBlock(); -} - -void ChatDocumentHandler::dumpHtml() -{ - qWarning() << htmlText(); -} - -QString ChatDocumentHandler::htmlText() const -{ - const auto doc = m_textItem->document(); - if (!doc) { - return {}; - } - return trim(doc->toMarkdown()); -} - -QString ChatDocumentHandler::trim(QString string) const -{ - while (string.startsWith(u"\n"_s)) { - string.removeFirst(); - } - while (string.endsWith(u"\n"_s)) { - string.removeLast(); - } - return string; -} - -#include "moc_chatdocumenthandler.cpp" diff --git a/src/libneochat/chatdocumenthandler.h b/src/libneochat/chatdocumenthandler.h deleted file mode 100644 index 258d365e4..000000000 --- a/src/libneochat/chatdocumenthandler.h +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Carl Schwan -// SPDX-FileCopyrightText: 2025 James Graham -// SPDX-License-Identifier: GPL-2.0-or-later - -#pragma once - -#include -#include -#include -#include -#include - -#include "chatbarcache.h" -#include "chatmarkdownhelper.h" -#include "enums/chatbartype.h" -#include "enums/richformat.h" -#include "neochatroom.h" -#include "nestedlisthelper_p.h" - -class QTextDocument; - -class QmlTextItemWrapper; -class NeoChatRoom; -class SyntaxHighlighter; - -/** - * @class ChatDocumentHandler - * - * Handle the QQuickTextDocument of a qml text item. - * - * The class provides functionality to highlight text in the text document as well - * as providing completion functionality via a CompletionModel. - * - * The ChatDocumentHandler is also linked to a NeoChatRoom to provide functionality - * to save the chat document text when switching between rooms. - * - * To get the full functionality the cursor position and text selection information - * need to be passed in. For example: - * - * @code{.qml} - * import QtQuick 2.0 - * import QtQuick.Controls 2.15 as QQC2 - * - * import org.kde.kirigami 2.12 as Kirigami - * import org.kde.neochat 1.0 - * - * QQC2.TextArea { - * id: textField - * - * // Set this to a NeoChatRoom object. - * property var room - * - * ChatDocumentHandler { - * id: documentHandler - * document: textField.textDocument - * cursorPosition: textField.cursorPosition - * selectionStart: textField.selectionStart - * selectionEnd: textField.selectionEnd - * mentionColor: Kirigami.Theme.linkColor - * errorColor: Kirigami.Theme.negativeTextColor - * room: textField.room - * } - * } - * @endcode - * - * @sa QQuickTextDocument, CompletionModel, NeoChatRoom - */ -class ChatDocumentHandler : public QObject -{ - Q_OBJECT - QML_ELEMENT - - /** - * @brief The QQuickTextDocument that is being handled. - */ - Q_PROPERTY(ChatBarType::Type type READ type WRITE setType NOTIFY typeChanged) - - /** - * @brief The current room that the text document is being handled for. - */ - Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) - - /** - * @brief The QML text Item the ChatDocumentHandler is handling. - */ - Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) - - /** - * @brief Whether the cursor is currently on the first line. - */ - Q_PROPERTY(bool atFirstLine READ atFirstLine NOTIFY atFirstLineChanged) - - /** - * @brief Whether the cursor is cuurently on the last line. - */ - Q_PROPERTY(bool atLastLine READ atLastLine NOTIFY atLastLineChanged) - -public: - enum InsertPosition { - Cursor, - Start, - End, - }; - - explicit ChatDocumentHandler(QObject *parent = nullptr); - - ChatBarType::Type type() const; - void setType(ChatBarType::Type type); - - [[nodiscard]] NeoChatRoom *room() const; - void setRoom(NeoChatRoom *room); - - QQuickItem *textItem() const; - void setTextItem(QQuickItem *textItem); - - ChatDocumentHandler *previousDocumentHandler() const; - void setPreviousDocumentHandler(ChatDocumentHandler *previousDocumentHandler); - - ChatDocumentHandler *nextDocumentHandler() const; - void setNextDocumentHandler(ChatDocumentHandler *nextDocumentHandler); - - QString fixedStartChars() const; - void setFixedStartChars(const QString &chars); - QString fixedEndChars() const; - void setFixedEndChars(const QString &chars); - QString initialText() const; - void setInitialText(const QString &text); - - bool isEmpty() const; - bool atFirstLine() const; - bool atLastLine() const; - void setCursorFromDocumentHandler(ChatDocumentHandler *previousDocumentHandler, bool infront, int defaultPosition = 0); - int lineCount() const; - std::optional lineLength(int lineNumber) const; - int cursorPositionInLine() const; - QTextDocumentFragment takeFirstBlock(); - void fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional &afterFragment); - - /** - * @brief Update the mentions in @p document when editing a message. - */ - Q_INVOKABLE void updateMentions(const QString &editId); - - Q_INVOKABLE void tab(); - Q_INVOKABLE void deleteChar(); - Q_INVOKABLE void backspace(); - Q_INVOKABLE void insertReturn(); - void insertFragment(const QTextDocumentFragment fragment, InsertPosition position = Cursor, bool keepPosition = false); - Q_INVOKABLE void insertCompletion(const QString &text, const QUrl &link); - - Q_INVOKABLE void dumpHtml(); - Q_INVOKABLE QString htmlText() const; - -Q_SIGNALS: - void typeChanged(); - void textItemChanged(); - void roomChanged(); - - void atFirstLineChanged(); - void atLastLineChanged(); - - void currentListStyleChanged(); - - void formatChanged(); - void textFormatChanged(); - void styleChanged(); - - void contentsChanged(); - - void unhandledBackspaceAtBeginning(ChatDocumentHandler *self); - void removeMe(ChatDocumentHandler *self); - -private: - ChatBarType::Type m_type = ChatBarType::None; - QPointer m_room; - QPointer m_textItem; - void connectTextItem(); - - QPointer m_previousDocumentHandler; - QPointer m_nextDocumentHandler; - - QString m_fixedStartChars = {}; - QString m_fixedEndChars = {}; - QString m_initialText = {}; - void initializeChars(); - - SyntaxHighlighter *m_highlighter = nullptr; - - QString getText() const; - void pushMention(const Mention mention) const; - - std::optional textFormat() const; - void mergeFormatOnWordOrSelection(const QTextCharFormat &format); - - QString trim(QString string) const; -}; diff --git a/src/libneochat/chatkeyhelper.cpp b/src/libneochat/chatkeyhelper.cpp new file mode 100644 index 000000000..a7c4bb1ef --- /dev/null +++ b/src/libneochat/chatkeyhelper.cpp @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "chatkeyhelper.h" + +ChatKeyHelper::ChatKeyHelper(QObject *parent) + : QObject(parent) +{ +} + +ChatTextItemHelper *ChatKeyHelper::textItem() const +{ + return m_textItem; +} + +void ChatKeyHelper::setTextItem(ChatTextItemHelper *textItem) +{ + if (textItem == m_textItem) { + return; + } + + if (m_textItem) { + m_textItem->disconnect(this); + } + + m_textItem = textItem; + + if (m_textItem) { + connect(m_textItem, &ChatTextItemHelper::textItemChanged, this, &ChatKeyHelper::textItemChanged); + } + + Q_EMIT textItemChanged(); +} + +void ChatKeyHelper::up() +{ + if (!m_textItem) { + return; + } + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + if (cursor.blockNumber() == 0 && cursor.block().layout()->lineForTextPosition(cursor.positionInBlock()).lineNumber() == 0) { + Q_EMIT unhandledUp(); + return; + } + cursor.movePosition(QTextCursor::Up); + m_textItem->setCursorPosition(cursor.position()); +} + +void ChatKeyHelper::down() +{ + if (!m_textItem) { + return; + } + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + if (cursor.blockNumber() == cursor.document()->blockCount() - 1 + && cursor.block().layout()->lineForTextPosition(cursor.positionInBlock()).lineNumber() == (cursor.block().layout()->lineCount() - 1)) { + Q_EMIT unhandledDown(); + return; + } + cursor.movePosition(QTextCursor::Down); + m_textItem->setCursorPosition(cursor.position()); +} + +void ChatKeyHelper::tab() +{ + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + if (cursor.currentList()) { + m_textItem->indentListMoreAtCursor(); + return; + } + cursor.insertText(u" "_s); +} + +void ChatKeyHelper::deleteChar() +{ + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + if (cursor.position() >= m_textItem->document()->characterCount() - m_textItem->fixedEndChars().length() - 1) { + Q_EMIT unhandledDelete(); + return; + } + cursor.deleteChar(); +} + +void ChatKeyHelper::backspace() +{ + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + if (cursor.position() <= m_textItem->fixedStartChars().length()) { + if (cursor.currentList()) { + m_textItem->indentListLessAtCursor(); + return; + } + Q_EMIT unhandledBackspace(); + return; + } + cursor.deletePreviousChar(); +} + +void ChatKeyHelper::insertReturn() +{ + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + cursor.insertBlock(); +} + +#include "moc_chatkeyhelper.cpp" diff --git a/src/libneochat/chatkeyhelper.h b/src/libneochat/chatkeyhelper.h new file mode 100644 index 000000000..e230dabf8 --- /dev/null +++ b/src/libneochat/chatkeyhelper.h @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include "chattextitemhelper.h" + +class ChatKeyHelper : public QObject +{ + Q_OBJECT + QML_ELEMENT + +public: + explicit ChatKeyHelper(QObject *parent = nullptr); + + ChatTextItemHelper *textItem() const; + void setTextItem(ChatTextItemHelper *textItem); + + /** + * @brief Handle up key at current cursor location. + */ + Q_INVOKABLE void up(); + + /** + * @brief Handle down key at current cursor location. + */ + Q_INVOKABLE void down(); + + /** + * @brief Handle tab key at current cursor location. + */ + Q_INVOKABLE void tab(); + + /** + * @brief Handle delete key at current cursor location. + */ + Q_INVOKABLE void deleteChar(); + + /** + * @brief Handle backspace key at current cursor location. + */ + Q_INVOKABLE void backspace(); + + /** + * @brief Handle return key at current cursor location. + */ + Q_INVOKABLE void insertReturn(); + +Q_SIGNALS: + void textItemChanged(); + + /** + * @brief There is an unhandled up key press. + * + * i.e. up is pressed on the first line of the first block of the text item. + */ + void unhandledUp(); + + /** + * @brief There is an unhandled down key press. + * + * i.e. down is pressed on the last line of the last block of the text item. + */ + void unhandledDown(); + + /** + * @brief There is an unhandled delete key press. + * + * i.e. delete is pressed at the end of the last line of the last block of the + * text item. + */ + void unhandledDelete(); + + /** + * @brief There is an unhandled backspace key press. + * + * i.e. backspace is pressed at the beginning of the first line of the first + * block of the text item. + */ + void unhandledBackspace(); + +private: + QPointer m_textItem; +}; diff --git a/src/libneochat/chatmarkdownhelper.cpp b/src/libneochat/chatmarkdownhelper.cpp index 05ab8995e..2dda8af31 100644 --- a/src/libneochat/chatmarkdownhelper.cpp +++ b/src/libneochat/chatmarkdownhelper.cpp @@ -7,7 +7,7 @@ #include #include -#include "qmltextitemwrapper.h" +#include "chattextitemhelper.h" #include "richformat.h" namespace @@ -86,12 +86,12 @@ ChatMarkdownHelper::ChatMarkdownHelper(QObject *parent) { } -QmlTextItemWrapper *ChatMarkdownHelper::textItem() const +ChatTextItemHelper *ChatMarkdownHelper::textItem() const { return m_textItem; } -void ChatMarkdownHelper::setTextItem(QmlTextItemWrapper *textItem) +void ChatMarkdownHelper::setTextItem(ChatTextItemHelper *textItem) { if (textItem == m_textItem) { return; @@ -104,15 +104,15 @@ void ChatMarkdownHelper::setTextItem(QmlTextItemWrapper *textItem) m_textItem = textItem; if (m_textItem) { - connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, &ChatMarkdownHelper::textItemChanged); - connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, [this]() { + connect(m_textItem, &ChatTextItemHelper::textItemChanged, this, &ChatMarkdownHelper::textItemChanged); + connect(m_textItem, &ChatTextItemHelper::textItemChanged, this, [this]() { m_startPos = m_textItem->cursorPosition(); m_endPos = m_startPos; if (m_startPos == 0) { m_currentState = Pre; } }); - connect(m_textItem, &QmlTextItemWrapper::contentsChange, this, &ChatMarkdownHelper::checkMarkdown); + connect(m_textItem, &ChatTextItemHelper::contentsChange, this, &ChatMarkdownHelper::checkMarkdown); } Q_EMIT textItemChanged(); diff --git a/src/libneochat/chatmarkdownhelper.h b/src/libneochat/chatmarkdownhelper.h index 75b29558e..cab1aed6c 100644 --- a/src/libneochat/chatmarkdownhelper.h +++ b/src/libneochat/chatmarkdownhelper.h @@ -10,7 +10,7 @@ class QQuickItem; class QTextDocument; -class QmlTextItemWrapper; +class ChatTextItemHelper; class ChatMarkdownHelper : public QObject { @@ -19,13 +19,20 @@ class ChatMarkdownHelper : public QObject public: explicit ChatMarkdownHelper(QObject *parent = nullptr); - QmlTextItemWrapper *textItem() const; - void setTextItem(QmlTextItemWrapper *textItem); + ChatTextItemHelper *textItem() const; + void setTextItem(ChatTextItemHelper *textItem); void handleExternalFormatChange(); Q_SIGNALS: void textItemChanged(); + + /** + * @brief There is an unhandled block format request. + * + * i.e. the markdown for as new block (e.g. code or quote) has been typed which + * ChatMarkdownHelper cannot resolve. + */ void unhandledBlockFormat(RichFormat::Format format); private: @@ -35,7 +42,7 @@ private: Started, }; - QPointer m_textItem; + QPointer m_textItem; State m_currentState = None; int m_startPos = 0; diff --git a/src/libneochat/chattextitemhelper.cpp b/src/libneochat/chattextitemhelper.cpp new file mode 100644 index 000000000..649e7a8b7 --- /dev/null +++ b/src/libneochat/chattextitemhelper.cpp @@ -0,0 +1,543 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "chattextitemhelper.h" +#include "richformat.h" + +#include +#include +#include + +#include + +#include "chatbarsyntaxhighlighter.h" +#include "neochatroom.h" + +ChatTextItemHelper::ChatTextItemHelper(QObject *parent) + : QObject(parent) + , m_highlighter(new ChatBarSyntaxHighlighter(this)) +{ +} + +void ChatTextItemHelper::setRoom(NeoChatRoom *room) +{ + m_highlighter->room = room; +} + +void ChatTextItemHelper::setType(ChatBarType::Type type) +{ + m_highlighter->type = type; +} + +QQuickItem *ChatTextItemHelper::textItem() const +{ + return m_textItem; +} + +void ChatTextItemHelper::setTextItem(QQuickItem *textItem) +{ + if (textItem == m_textItem) { + return; + } + + if (m_textItem) { + m_textItem->disconnect(this); + if (const auto textDoc = document()) { + textDoc->disconnect(this); + } + } + + m_textItem = textItem; + + if (m_textItem) { + connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(itemCursorPositionChanged())); + if (const auto doc = document()) { + connect(doc, &QTextDocument::contentsChanged, this, &ChatTextItemHelper::contentsChanged); + connect(doc, &QTextDocument::contentsChange, this, &ChatTextItemHelper::contentsChange); + m_highlighter->setDocument(doc); + } + initializeChars(); + } + + Q_EMIT textItemChanged(); + Q_EMIT formatChanged(); + Q_EMIT textFormatChanged(); + Q_EMIT styleChanged(); + Q_EMIT listChanged(); +} + +std::optional ChatTextItemHelper::textFormat() const +{ + if (!m_textItem) { + return std::nullopt; + } + + return static_cast(m_textItem->property("textFormat").toInt()); +} + +QString ChatTextItemHelper::fixedStartChars() const +{ + return m_fixedStartChars; +} + +QString ChatTextItemHelper::fixedEndChars() const +{ + return m_fixedEndChars; + ; +} + +void ChatTextItemHelper::setFixedChars(const QString &startChars, const QString &endChars) +{ + if (startChars == m_fixedStartChars && endChars == m_fixedEndChars) { + return; + } + m_fixedStartChars = startChars; + m_fixedEndChars = endChars; + initializeChars(); +} + +QString ChatTextItemHelper::initialText() const +{ + return m_initialText; +} + +void ChatTextItemHelper::setInitialText(const QString &text) +{ + if (text == m_initialText) { + return; + } + m_initialText = text; + initializeChars(); +} + +void ChatTextItemHelper::initializeChars() +{ + const auto doc = document(); + if (!doc) { + return; + } + + QTextCursor cursor = QTextCursor(doc); + if (cursor.isNull()) { + return; + } + + if (doc->isEmpty() && !m_initialText.isEmpty()) { + cursor.insertText(m_initialText); + } + + if (!m_fixedStartChars.isEmpty() && doc->characterAt(0) != m_fixedStartChars) { + cursor.movePosition(QTextCursor::Start); + cursor.insertText(m_fixedEndChars); + } + + if (!m_fixedStartChars.isEmpty() && doc->characterAt(doc->characterCount()) != m_fixedStartChars) { + cursor.movePosition(QTextCursor::End); + cursor.insertText(m_fixedEndChars); + } +} + +QTextDocument *ChatTextItemHelper::document() const +{ + if (!m_textItem) { + return nullptr; + } + const auto quickDocument = qvariant_cast(textItem()->property("textDocument")); + return quickDocument ? quickDocument->textDocument() : nullptr; +} + +bool ChatTextItemHelper::isEmpty() const +{ + return markdownText().length() == 0; +} + +int ChatTextItemHelper::lineCount() const +{ + if (const auto doc = document()) { + return doc->lineCount(); + } + return 0; +} + +std::optional ChatTextItemHelper::lineLength(int lineNumber) const +{ + const auto doc = document(); + if (!doc || lineNumber < 0 || lineNumber >= doc->lineCount()) { + return std::nullopt; + } + const auto block = doc->findBlockByLineNumber(lineNumber); + const auto lineNumInBlock = lineNumber - block.firstLineNumber(); + return block.layout()->lineAt(lineNumInBlock).textLength(); +} + +QTextDocumentFragment ChatTextItemHelper::takeFirstBlock() +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return {}; + } + cursor.beginEditBlock(); + cursor.movePosition(QTextCursor::Start); + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor, m_fixedStartChars.length()); + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + if (document()->blockCount() <= 1) { + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor, m_fixedEndChars.length()); + } + + const auto block = cursor.selection(); + cursor.removeSelectedText(); + cursor.endEditBlock(); + if (document()->characterCount() - 1 <= (m_fixedStartChars.length() + m_fixedEndChars.length())) { + Q_EMIT cleared(this); + } + return block; +} + +void ChatTextItemHelper::fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional &afterFragment) +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return; + } + + if (cursor.blockNumber() > 0) { + hasBefore = true; + } + auto afterBlock = cursor.blockNumber() < document()->blockCount() - 1; + + cursor.beginEditBlock(); + cursor.movePosition(QTextCursor::StartOfBlock); + if (!hasBefore) { + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor, m_fixedStartChars.length()); + } + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + if (!afterBlock) { + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor, m_fixedEndChars.length()); + } + cursor.endEditBlock(); + + midFragment = cursor.selection(); + if (!midFragment.isEmpty()) { + cursor.removeSelectedText(); + } + cursor.deletePreviousChar(); + if (afterBlock) { + cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor); + afterFragment = cursor.selection(); + cursor.removeSelectedText(); + } +} + +void ChatTextItemHelper::insertFragment(const QTextDocumentFragment fragment, InsertPosition position, bool keepPosition) +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return; + } + + int currentPosition; + switch (position) { + case Start: + currentPosition = 0; + break; + case End: + currentPosition = document()->characterCount() - 1; + break; + case Cursor: + currentPosition = cursor.position(); + break; + } + + if (currentPosition < m_fixedStartChars.length()) { + currentPosition = m_fixedStartChars.length(); + } + if (currentPosition >= document()->characterCount() - 1 - m_fixedEndChars.length()) { + currentPosition = document()->characterCount() - 1 - m_fixedEndChars.length(); + } + + cursor.setPosition(currentPosition); + if (textFormat() && textFormat() == Qt::PlainText) { + const auto wasEmpty = isEmpty(); + auto text = fragment.toPlainText(); + text = trim(text); + cursor.insertText(text); + if (wasEmpty) { + cursor.movePosition(QTextCursor::StartOfBlock); + cursor.deletePreviousChar(); + cursor.movePosition(QTextCursor::EndOfBlock); + cursor.deleteChar(); + } + } else { + cursor.insertMarkdown(trim(fragment.toMarkdown())); + } + if (keepPosition) { + cursor.setPosition(currentPosition); + } + setCursorPosition(cursor.position()); +} + +int ChatTextItemHelper::cursorPosition() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("cursorPosition").toInt(); +} + +int ChatTextItemHelper::selectionStart() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("selectionStart").toInt(); +} + +int ChatTextItemHelper::selectionEnd() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("selectionEnd").toInt(); +} + +QTextCursor ChatTextItemHelper::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; +} + +void ChatTextItemHelper::setCursorPosition(int pos) +{ + if (!m_textItem) { + return; + } + m_textItem->setProperty("cursorPosition", pos); +} + +void ChatTextItemHelper::setCursorVisible(bool visible) +{ + if (!m_textItem) { + return; + } + m_textItem->setProperty("cursorVisible", visible); +} + +void ChatTextItemHelper::setCursorFromTextItem(ChatTextItemHelper *textItem, bool infront, int defaultPosition) +{ + const auto doc = document(); + if (!doc) { + return; + } + + m_textItem->forceActiveFocus(); + + if (!textItem) { + const auto docLastBlockLayout = doc->lastBlock().layout(); + setCursorPosition(infront ? defaultPosition : docLastBlockLayout->lineAt(docLastBlockLayout->lineCount() - 1).textStart()); + setCursorVisible(true); + return; + } + + const auto previousLinePosition = textItem->textCursor().positionInBlock(); + const auto newMaxLineLength = lineLength(infront ? 0 : lineCount() - 1); + setCursorPosition(std::min(previousLinePosition, newMaxLineLength ? *newMaxLineLength : defaultPosition) + (infront ? 0 : doc->lastBlock().position())); + setCursorVisible(true); +} + +void ChatTextItemHelper::itemCursorPositionChanged() +{ + Q_EMIT cursorPositionChanged(); + Q_EMIT formatChanged(); + Q_EMIT textFormatChanged(); + Q_EMIT styleChanged(); + Q_EMIT listChanged(); +} + +QList ChatTextItemHelper::formatsAtCursor(QTextCursor cursor) const +{ + if (cursor.isNull()) { + cursor = textCursor(); + if (cursor.isNull()) { + return {}; + } + } + return RichFormat::formatsAtCursor(cursor); +} + +void ChatTextItemHelper::mergeFormatOnCursor(RichFormat::Format format, QTextCursor cursor) +{ + if (cursor.isNull()) { + cursor = textCursor(); + if (cursor.isNull()) { + return; + } + } + switch (RichFormat::typeForFormat(format)) { + case RichFormat::Text: + mergeTextFormatOnCursor(format, cursor); + return; + case RichFormat::List: + mergeListFormatOnCursor(format, cursor); + return; + case RichFormat::Block: + if (format != RichFormat::Paragraph) { + return; + } + case RichFormat::Style: + mergeStyleFormatOnCursor(format, cursor); + return; + default: + return; + } +} + +void ChatTextItemHelper::mergeTextFormatOnCursor(RichFormat::Format format, QTextCursor cursor) +{ + if (RichFormat::typeForFormat(format) != RichFormat::Text) { + return; + } + + const auto theme = static_cast(qmlAttachedPropertiesObject(this, true)); + const auto charFormat = RichFormat::charFormatForFormat(format, RichFormat::hasFormat(cursor, format), theme->alternateBackgroundColor()); + if (!cursor.hasSelection()) { + cursor.select(QTextCursor::WordUnderCursor); + } + cursor.mergeCharFormat(charFormat); + Q_EMIT formatChanged(); + Q_EMIT textFormatChanged(); +} + +void ChatTextItemHelper::mergeStyleFormatOnCursor(RichFormat::Format format, QTextCursor cursor) +{ + // 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; + } + + cursor.beginEditBlock(); + cursor.mergeBlockFormat(RichFormat::blockFormatForFormat(format)); + + // Applying style to the current line or selection + QTextCursor selectCursor = cursor; + if (selectCursor.hasSelection()) { + QTextCursor top = selectCursor; + top.setPosition(qMin(top.anchor(), top.position())); + top.movePosition(QTextCursor::StartOfBlock); + + QTextCursor bottom = selectCursor; + bottom.setPosition(qMax(bottom.anchor(), bottom.position())); + bottom.movePosition(QTextCursor::EndOfBlock); + + selectCursor.setPosition(top.position(), QTextCursor::MoveAnchor); + selectCursor.setPosition(bottom.position(), QTextCursor::KeepAnchor); + } else { + selectCursor.select(QTextCursor::BlockUnderCursor); + } + + const auto chrfmt = RichFormat::charFormatForFormat(format); + selectCursor.mergeCharFormat(chrfmt); + cursor.mergeBlockCharFormat(chrfmt); + cursor.endEditBlock(); + + Q_EMIT formatChanged(); + Q_EMIT styleChanged(); +} + +void ChatTextItemHelper::mergeListFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor) +{ + m_nestedListHelper.handleOnBulletType(RichFormat::listStyleForFormat(format), cursor); + Q_EMIT formatChanged(); + Q_EMIT listChanged(); +} + +bool ChatTextItemHelper::canIndentListMoreAtCursor(QTextCursor cursor) const +{ + if (cursor.isNull()) { + cursor = textCursor(); + if (cursor.isNull()) { + return false; + } + } + return m_nestedListHelper.canIndent(cursor) && cursor.blockFormat().headingLevel() == 0; +} + +bool ChatTextItemHelper::canIndentListLessAtCursor(QTextCursor cursor) const +{ + if (cursor.isNull()) { + cursor = textCursor(); + if (cursor.isNull()) { + return false; + } + } + return m_nestedListHelper.canDedent(cursor) && cursor.blockFormat().headingLevel() == 0; +} + +void ChatTextItemHelper::indentListMoreAtCursor(QTextCursor cursor) +{ + if (cursor.isNull()) { + cursor = textCursor(); + if (cursor.isNull()) { + return; + } + } + m_nestedListHelper.handleOnIndentMore(cursor); + Q_EMIT listChanged(); +} + +void ChatTextItemHelper::indentListLessAtCursor(QTextCursor cursor) +{ + if (cursor.isNull()) { + cursor = textCursor(); + if (cursor.isNull()) { + return; + } + } + m_nestedListHelper.handleOnIndentLess(cursor); + Q_EMIT listChanged(); +} + +void ChatTextItemHelper::forceActiveFocus() const +{ + if (!m_textItem) { + return; + } + m_textItem->forceActiveFocus(); +} + +void ChatTextItemHelper::rehighlight() const +{ + m_highlighter->rehighlight(); +} + +QString ChatTextItemHelper::markdownText() const +{ + const auto doc = document(); + if (!doc) { + return {}; + } + return trim(doc->toMarkdown()); +} + +QString ChatTextItemHelper::trim(QString string) const +{ + while (string.startsWith(u"\n"_s)) { + string.removeFirst(); + } + while (string.endsWith(u"\n"_s)) { + string.removeLast(); + } + return string; +} + +#include "moc_chattextitemhelper.cpp" diff --git a/src/libneochat/qmltextitemwrapper.h b/src/libneochat/chattextitemhelper.h similarity index 54% rename from src/libneochat/qmltextitemwrapper.h rename to src/libneochat/chattextitemhelper.h index 962327257..b93252ad5 100644 --- a/src/libneochat/qmltextitemwrapper.h +++ b/src/libneochat/chattextitemhelper.h @@ -5,14 +5,19 @@ #include #include +#include +#include "enums/chatbartype.h" #include "enums/richformat.h" #include "nestedlisthelper_p.h" class QTextDocument; +class ChatBarSyntaxHighlighter; +class NeoChatRoom; + /** - * @class QmlTextItemWrapper + * @class ChatTextItemHelper * * A class to wrap around a QQuickItem that is a QML TextEdit (or inherited from it). * @@ -21,22 +26,49 @@ class QTextDocument; * * @sa QQuickItem, TextEdit */ -class QmlTextItemWrapper : public QObject +class ChatTextItemHelper : public QObject { Q_OBJECT + QML_ELEMENT + + /** + * @brief The QML text Item the ChatTextItemHelper is handling. + */ + Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) public: - explicit QmlTextItemWrapper(QObject *parent); + enum InsertPosition { + Cursor, + Start, + End, + }; + + explicit ChatTextItemHelper(QObject *parent = nullptr); + + void setRoom(NeoChatRoom *room); + + void setType(ChatBarType::Type type); QQuickItem *textItem() const; void setTextItem(QQuickItem *textItem); + QString fixedStartChars() const; + QString fixedEndChars() const; + void setFixedChars(const QString &startChars, const QString &endChars); + QString initialText() const; + void setInitialText(const QString &text); + QTextDocument *document() const; + int lineCount() const; + QTextDocumentFragment takeFirstBlock(); + void fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional &afterFragment); + void insertFragment(const QTextDocumentFragment fragment, InsertPosition position = Cursor, bool keepPosition = false); QTextCursor textCursor() const; int cursorPosition() const; void setCursorPosition(int pos); void setCursorVisible(bool visible); + void setCursorFromTextItem(ChatTextItemHelper *textItem, bool infront, int defaultPosition = 0); QList formatsAtCursor(QTextCursor cursor = {}) const; void mergeFormatOnCursor(RichFormat::Format format, QTextCursor cursor = {}); @@ -48,6 +80,10 @@ public: void forceActiveFocus() const; + void rehighlight() const; + + QString markdownText() const; + Q_SIGNALS: void textItemChanged(); @@ -55,6 +91,8 @@ Q_SIGNALS: void contentsChanged(); + void cleared(ChatTextItemHelper *self); + void cursorPositionChanged(); void formatChanged(); @@ -64,6 +102,17 @@ Q_SIGNALS: private: QPointer m_textItem; + QPointer m_highlighter; + + std::optional textFormat() const; + + QString m_fixedStartChars = {}; + QString m_fixedEndChars = {}; + QString m_initialText = {}; + void initializeChars(); + + bool isEmpty() const; + std::optional lineLength(int lineNumber) const; int selectionStart() const; int selectionEnd() const; @@ -73,6 +122,8 @@ private: void mergeListFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor); NestedListHelper m_nestedListHelper; + QString trim(QString string) const; + private Q_SLOTS: void itemCursorPositionChanged(); }; diff --git a/src/libneochat/models/completionmodel.cpp b/src/libneochat/models/completionmodel.cpp index f8a9e6cbc..2832d9329 100644 --- a/src/libneochat/models/completionmodel.cpp +++ b/src/libneochat/models/completionmodel.cpp @@ -6,35 +6,75 @@ #include #include +#include "chattextitemhelper.h" #include "completionproxymodel.h" #include "models/actionsmodel.h" #include "models/customemojimodel.h" #include "models/emojimodel.h" -#include "qmltextitemwrapper.h" +#include "models/roomlistmodel.h" #include "userlistmodel.h" CompletionModel::CompletionModel(QObject *parent) : QAbstractListModel(parent) - , m_textItem(new QmlTextItemWrapper(this)) + , m_textItem(new ChatTextItemHelper(this)) , m_filterModel(new CompletionProxyModel(this)) , m_emojiModel(new QConcatenateTablesProxyModel(this)) { - connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, &CompletionModel::textItemChanged); - connect(m_textItem, &QmlTextItemWrapper::cursorPositionChanged, this, &CompletionModel::updateTextStart); - connect(m_textItem, &QmlTextItemWrapper::contentsChanged, this, &CompletionModel::updateCompletion); - m_emojiModel->addSourceModel(&CustomEmojiModel::instance()); m_emojiModel->addSourceModel(&EmojiModel::instance()); } -QQuickItem *CompletionModel::textItem() const +NeoChatRoom *CompletionModel::room() const { - return m_textItem->textItem(); + return m_room; } -void CompletionModel::setTextItem(QQuickItem *textItem) +void CompletionModel::setRoom(NeoChatRoom *room) { - m_textItem->setTextItem(textItem); + if (m_room == room) { + return; + } + + m_room = room; + Q_EMIT roomChanged(); +} + +ChatBarType::Type CompletionModel::type() const +{ + return m_type; +} + +void CompletionModel::setType(ChatBarType::Type type) +{ + if (type == m_type) { + return; + } + m_type = type; + Q_EMIT typeChanged(); +} + +ChatTextItemHelper *CompletionModel::textItem() const +{ + return m_textItem; +} + +void CompletionModel::setTextItem(ChatTextItemHelper *textItem) +{ + if (textItem == m_textItem) { + return; + } + + if (m_textItem) { + m_textItem->disconnect(this); + } + + m_textItem = textItem; + + if (m_textItem) { + connect(m_textItem, &ChatTextItemHelper::cursorPositionChanged, this, &CompletionModel::updateTextStart); + connect(m_textItem, &ChatTextItemHelper::contentsChanged, this, &CompletionModel::updateCompletion); + } + Q_EMIT textItemChanged(); } void CompletionModel::updateTextStart() @@ -239,4 +279,45 @@ void CompletionModel::setUserListModel(UserListModel *userListModel) Q_EMIT userListModelChanged(); } +void CompletionModel::insertCompletion(const QString &text, const QUrl &link) +{ + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + + cursor.beginEditBlock(); + while (!cursor.selectedText().startsWith(u' ') && !cursor.atBlockStart()) { + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + } + if (cursor.selectedText().startsWith(u' ')) { + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); + } + cursor.removeSelectedText(); + + const int start = cursor.position(); + const auto insertString = u"%1 %2"_s.arg(text, link.isEmpty() ? QString() : u" "_s); + cursor.insertText(insertString); + cursor.setPosition(start); + cursor.setPosition(start + text.size(), QTextCursor::KeepAnchor); + cursor.setKeepPositionOnInsert(true); + cursor.endEditBlock(); + if (!link.isEmpty()) { + pushMention({ + .cursor = cursor, + .text = text, + .id = link.toString(), + }); + } + m_textItem->rehighlight(); +} + +void CompletionModel::pushMention(const Mention mention) const +{ + if (!m_room || m_type == ChatBarType::None) { + return; + } + m_room->cacheForType(m_type)->mentions()->push_back(mention); +} + #include "moc_completionmodel.cpp" diff --git a/src/libneochat/models/completionmodel.h b/src/libneochat/models/completionmodel.h index 84f2c9933..01c0b2cfa 100644 --- a/src/libneochat/models/completionmodel.h +++ b/src/libneochat/models/completionmodel.h @@ -8,11 +8,13 @@ #include #include -#include "roomlistmodel.h" +#include "chatbarcache.h" +#include "chattextitemhelper.h" +#include "enums/chatbartype.h" +#include "neochatroom.h" class CompletionProxyModel; class UserListModel; -class QmlTextItemWrapper; class RoomListModel; /** @@ -28,10 +30,20 @@ class CompletionModel : public QAbstractListModel Q_OBJECT QML_ELEMENT + /** + * @brief The current room that the text document is being handled for. + */ + Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + + /** + * @brief The QQuickTextDocument that is being handled. + */ + Q_PROPERTY(ChatBarType::Type type READ type WRITE setType NOTIFY typeChanged) + /** * @brief The QML text Item that completions are being provided for. */ - Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) + Q_PROPERTY(ChatTextItemHelper *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) /** * @brief The current type of completion being done on the entered text. @@ -77,8 +89,14 @@ public: explicit CompletionModel(QObject *parent = nullptr); - QQuickItem *textItem() const; - void setTextItem(QQuickItem *textItem); + NeoChatRoom *room() const; + void setRoom(NeoChatRoom *room); + + ChatBarType::Type type() const; + void setType(ChatBarType::Type type); + + ChatTextItemHelper *textItem() const; + void setTextItem(ChatTextItemHelper *textItem); /** * @brief Get the given role value at the given index. @@ -110,16 +128,20 @@ public: AutoCompletionType autoCompletionType() const; void setAutoCompletionType(AutoCompletionType autoCompletionType); -Q_SIGNALS: - void textItemChanged(); + Q_INVOKABLE void insertCompletion(const QString &text, const QUrl &link); +Q_SIGNALS: void roomChanged(); + void typeChanged(); + void textItemChanged(); void autoCompletionTypeChanged(); void roomListModelChanged(); void userListModelChanged(); private: - QPointer m_textItem; + QPointer m_room; + ChatBarType::Type m_type = ChatBarType::None; + QPointer m_textItem; int m_textStart = 0; void updateTextStart(); @@ -132,5 +154,6 @@ private: UserListModel *m_userListModel; RoomListModel *m_roomListModel; QConcatenateTablesProxyModel *m_emojiModel; + + void pushMention(const Mention mention) const; }; -Q_DECLARE_METATYPE(CompletionModel::AutoCompletionType); diff --git a/src/libneochat/qmltextitemwrapper.cpp b/src/libneochat/qmltextitemwrapper.cpp deleted file mode 100644 index 44efe91f1..000000000 --- a/src/libneochat/qmltextitemwrapper.cpp +++ /dev/null @@ -1,278 +0,0 @@ -// SPDX-FileCopyrightText: 2025 James Graham -// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - -#include "qmltextitemwrapper.h" -#include "richformat.h" - -#include -#include - -#include - -QmlTextItemWrapper::QmlTextItemWrapper(QObject *parent) - : QObject(parent) -{ -} - -QQuickItem *QmlTextItemWrapper::textItem() const -{ - return m_textItem; -} - -void QmlTextItemWrapper::setTextItem(QQuickItem *textItem) -{ - if (textItem == m_textItem) { - return; - } - - if (m_textItem) { - m_textItem->disconnect(this); - if (const auto textDoc = document()) { - textDoc->disconnect(this); - } - } - - m_textItem = textItem; - - if (m_textItem) { - connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(itemCursorPositionChanged())); - if (document()) { - connect(document(), &QTextDocument::contentsChanged, this, &QmlTextItemWrapper::contentsChanged); - connect(document(), &QTextDocument::contentsChange, this, &QmlTextItemWrapper::contentsChange); - } - } - - Q_EMIT textItemChanged(); - Q_EMIT formatChanged(); - Q_EMIT textFormatChanged(); - Q_EMIT styleChanged(); - Q_EMIT listChanged(); -} - -QTextDocument *QmlTextItemWrapper::document() const -{ - if (!m_textItem) { - return nullptr; - } - const auto quickDocument = qvariant_cast(textItem()->property("textDocument")); - return quickDocument ? quickDocument->textDocument() : nullptr; -} - -int QmlTextItemWrapper::cursorPosition() const -{ - if (!m_textItem) { - return -1; - } - return m_textItem->property("cursorPosition").toInt(); -} - -int QmlTextItemWrapper::selectionStart() const -{ - if (!m_textItem) { - return -1; - } - return m_textItem->property("selectionStart").toInt(); -} - -int QmlTextItemWrapper::selectionEnd() const -{ - if (!m_textItem) { - return -1; - } - return m_textItem->property("selectionEnd").toInt(); -} - -QTextCursor QmlTextItemWrapper::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; -} - -void QmlTextItemWrapper::setCursorPosition(int pos) -{ - if (!m_textItem) { - return; - } - m_textItem->setProperty("cursorPosition", pos); -} - -void QmlTextItemWrapper::setCursorVisible(bool visible) -{ - if (!m_textItem) { - return; - } - m_textItem->setProperty("cursorVisible", visible); -} - -void QmlTextItemWrapper::itemCursorPositionChanged() -{ - Q_EMIT cursorPositionChanged(); - Q_EMIT formatChanged(); - Q_EMIT textFormatChanged(); - Q_EMIT styleChanged(); - Q_EMIT listChanged(); -} - -QList QmlTextItemWrapper::formatsAtCursor(QTextCursor cursor) const -{ - if (cursor.isNull()) { - cursor = textCursor(); - if (cursor.isNull()) { - return {}; - } - } - return RichFormat::formatsAtCursor(cursor); -} - -void QmlTextItemWrapper::mergeFormatOnCursor(RichFormat::Format format, QTextCursor cursor) -{ - if (cursor.isNull()) { - cursor = textCursor(); - if (cursor.isNull()) { - return; - } - } - switch (RichFormat::typeForFormat(format)) { - case RichFormat::Text: - mergeTextFormatOnCursor(format, cursor); - return; - case RichFormat::List: - mergeListFormatOnCursor(format, cursor); - return; - case RichFormat::Block: - if (format != RichFormat::Paragraph) { - return; - } - case RichFormat::Style: - mergeStyleFormatOnCursor(format, cursor); - return; - default: - return; - } -} - -void QmlTextItemWrapper::mergeTextFormatOnCursor(RichFormat::Format format, QTextCursor cursor) -{ - if (RichFormat::typeForFormat(format) != RichFormat::Text) { - return; - } - - const auto theme = static_cast(qmlAttachedPropertiesObject(this, true)); - const auto charFormat = RichFormat::charFormatForFormat(format, RichFormat::hasFormat(cursor, format), theme->alternateBackgroundColor()); - if (!cursor.hasSelection()) { - cursor.select(QTextCursor::WordUnderCursor); - } - cursor.mergeCharFormat(charFormat); - Q_EMIT formatChanged(); - Q_EMIT textFormatChanged(); -} - -void QmlTextItemWrapper::mergeStyleFormatOnCursor(RichFormat::Format format, QTextCursor cursor) -{ - // 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; - } - - cursor.beginEditBlock(); - cursor.mergeBlockFormat(RichFormat::blockFormatForFormat(format)); - - // Applying style to the current line or selection - QTextCursor selectCursor = cursor; - if (selectCursor.hasSelection()) { - QTextCursor top = selectCursor; - top.setPosition(qMin(top.anchor(), top.position())); - top.movePosition(QTextCursor::StartOfBlock); - - QTextCursor bottom = selectCursor; - bottom.setPosition(qMax(bottom.anchor(), bottom.position())); - bottom.movePosition(QTextCursor::EndOfBlock); - - selectCursor.setPosition(top.position(), QTextCursor::MoveAnchor); - selectCursor.setPosition(bottom.position(), QTextCursor::KeepAnchor); - } else { - selectCursor.select(QTextCursor::BlockUnderCursor); - } - - const auto chrfmt = RichFormat::charFormatForFormat(format); - selectCursor.mergeCharFormat(chrfmt); - cursor.mergeBlockCharFormat(chrfmt); - cursor.endEditBlock(); - - Q_EMIT formatChanged(); - Q_EMIT styleChanged(); -} - -void QmlTextItemWrapper::mergeListFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor) -{ - m_nestedListHelper.handleOnBulletType(RichFormat::listStyleForFormat(format), cursor); - Q_EMIT formatChanged(); - Q_EMIT listChanged(); -} - -bool QmlTextItemWrapper::canIndentListMoreAtCursor(QTextCursor cursor) const -{ - if (cursor.isNull()) { - cursor = textCursor(); - if (cursor.isNull()) { - return false; - } - } - return m_nestedListHelper.canIndent(cursor) && cursor.blockFormat().headingLevel() == 0; -} - -bool QmlTextItemWrapper::canIndentListLessAtCursor(QTextCursor cursor) const -{ - if (cursor.isNull()) { - cursor = textCursor(); - if (cursor.isNull()) { - return false; - } - } - return m_nestedListHelper.canDedent(cursor) && cursor.blockFormat().headingLevel() == 0; -} - -void QmlTextItemWrapper::indentListMoreAtCursor(QTextCursor cursor) -{ - if (cursor.isNull()) { - cursor = textCursor(); - if (cursor.isNull()) { - return; - } - } - m_nestedListHelper.handleOnIndentMore(cursor); - Q_EMIT listChanged(); -} - -void QmlTextItemWrapper::indentListLessAtCursor(QTextCursor cursor) -{ - if (cursor.isNull()) { - cursor = textCursor(); - if (cursor.isNull()) { - return; - } - } - m_nestedListHelper.handleOnIndentLess(cursor); - Q_EMIT listChanged(); -} - -void QmlTextItemWrapper::forceActiveFocus() const -{ - if (!m_textItem) { - return; - } - m_textItem->forceActiveFocus(); -} - -#include "moc_qmltextitemwrapper.cpp" diff --git a/src/messagecontent/ChatBarComponent.qml b/src/messagecontent/ChatBarComponent.qml index 0b3d84521..02580e424 100644 --- a/src/messagecontent/ChatBarComponent.qml +++ b/src/messagecontent/ChatBarComponent.qml @@ -110,8 +110,14 @@ QQC2.Control { height: implicitHeight y: -height - 5 z: 10 +<<<<<<< HEAD chatDocumentHandler: documentHandler +======= + room: root.Message.room + type: root.chatBarCache.isEditing ? ChatBarType.Edit : ChatBarType.Thread + // textItem: textArea +>>>>>>> c7858a151 (Move the remaining functionality of ChatDocumentHandler to ChatTextItemHelper or split into own objects) margins: 0 Behavior on height { NumberAnimation { @@ -125,13 +131,6 @@ QQC2.Control { // opt-out of whatever spell checker a styled TextArea might come with Kirigami.SpellCheck.enabled: false - ChatDocumentHandler { - id: documentHandler - type: root.chatBarCache.isEditing ? ChatBarType.Edit : ChatBarType.Thread - textItem: textArea - room: root.Message.room - } - TextMetrics { id: textMetrics text: textArea.text diff --git a/src/messagecontent/CodeComponent.qml b/src/messagecontent/CodeComponent.qml index 8c7c7f948..0382c3b34 100644 --- a/src/messagecontent/CodeComponent.qml +++ b/src/messagecontent/CodeComponent.qml @@ -51,11 +51,9 @@ QQC2.Control { * @brief The attributes of the component. */ required property var componentAttributes - readonly property ChatDocumentHandler chatDocumentHandler: componentAttributes?.chatDocumentHandler ?? null - onChatDocumentHandlerChanged: if (chatDocumentHandler) { - chatDocumentHandler.type = ChatBarType.Room; - chatDocumentHandler.room = root.Message.room; - chatDocumentHandler.textItem = codeText; + readonly property ChatTextItemHelper chatTextItemHelper: componentAttributes?.chatTextItemHelper ?? null + onChatTextItemHelperChanged: if (chatTextItemHelper) { + chatTextItemHelper.textItem = codeText; } /** @@ -94,27 +92,23 @@ QQC2.Control { id: codeText Keys.onUpPressed: (event) => { - event.accepted = false; - if (root.chatDocumentHandler.atFirstLine) { - Message.contentModel.focusRow = root.index - 1 - } + event.accepted = true; + Message.contentModel.keyHelper.up(); } Keys.onDownPressed: (event) => { - event.accepted = false; - if (root.chatDocumentHandler.atLastLine) { - Message.contentModel.focusRow = root.index + 1 - } + event.accepted = true; + Message.contentModel.keyHelper.down(); } Keys.onDeletePressed: (event) => { event.accepted = true; - root.chatDocumentHandler.deleteChar(); + root.Message.contentModel.keyHelper.deleteChar(); } Keys.onPressed: (event) => { if (event.key == Qt.Key_Backspace && cursorPosition == 0) { event.accepted = true; - root.chatDocumentHandler.backspace(); + root.Message.contentModel.keyHelper.backspace(); return; } event.accepted = false; diff --git a/src/messagecontent/QuoteComponent.qml b/src/messagecontent/QuoteComponent.qml index 34ebc6254..e407f4aeb 100644 --- a/src/messagecontent/QuoteComponent.qml +++ b/src/messagecontent/QuoteComponent.qml @@ -45,11 +45,9 @@ QQC2.TextArea { * @brief The attributes of the component. */ required property var componentAttributes - readonly property ChatDocumentHandler chatDocumentHandler: componentAttributes?.chatDocumentHandler ?? null - onChatDocumentHandlerChanged: if (chatDocumentHandler) { - chatDocumentHandler.type = ChatBarType.Room; - chatDocumentHandler.room = root.Message.room; - chatDocumentHandler.textItem = root; + readonly property ChatTextItemHelper chatTextItemHelper: componentAttributes?.chatTextItemHelper ?? null + onChatTextItemHelperChanged: if (chatTextItemHelper) { + chatTextItemHelper.textItem = root; } /** @@ -66,16 +64,12 @@ QQC2.TextArea { signal selectedTextChanged(string selectedText) Keys.onUpPressed: (event) => { - event.accepted = false; - if (root.chatDocumentHandler.atFirstLine) { - Message.contentModel.focusRow = root.index - 1 - } + event.accepted = true; + Message.contentModel.keyHelper.up(); } Keys.onDownPressed: (event) => { - event.accepted = false; - if (root.chatDocumentHandler.atLastLine) { - Message.contentModel.focusRow = root.index + 1 - } + event.accepted = true; + Message.contentModel.keyHelper.down(); } Keys.onLeftPressed: (event) => { if (cursorPosition == 1) { @@ -94,12 +88,12 @@ QQC2.TextArea { Keys.onDeletePressed: (event) => { event.accepted = true; - chatDocumentHandler.deleteChar(); + Message.contentModel.keyHelper.deleteChar(); } Keys.onPressed: (event) => { if (event.key == Qt.Key_Backspace) { event.accepted = true; - chatDocumentHandler.backspace(); + Message.contentModel.keyHelper.backspace(); return; } event.accepted = false; diff --git a/src/messagecontent/TextComponent.qml b/src/messagecontent/TextComponent.qml index 6b007d4da..9a49b474e 100644 --- a/src/messagecontent/TextComponent.qml +++ b/src/messagecontent/TextComponent.qml @@ -49,11 +49,9 @@ TextEdit { * @brief The attributes of the component. */ required property var componentAttributes - readonly property ChatDocumentHandler chatDocumentHandler: componentAttributes?.chatDocumentHandler ?? null - onChatDocumentHandlerChanged: if (chatDocumentHandler) { - chatDocumentHandler.type = ChatBarType.Room; - chatDocumentHandler.room = root.Message.room; - chatDocumentHandler.textItem = root; + readonly property ChatTextItemHelper chatTextItemHelper: componentAttributes?.chatTextItemHelper ?? null + onChatTextItemHelperChanged: if (chatTextItemHelper) { + chatTextItemHelper.textItem = root; } /** @@ -78,32 +76,28 @@ TextEdit { Layout.maximumWidth: Message.maxContentWidth Keys.onUpPressed: (event) => { - event.accepted = false; - if (chatDocumentHandler.atFirstLine) { - Message.contentModel.focusRow = root.index - 1 - } + event.accepted = true; + Message.contentModel.keyHelper.up(); } Keys.onDownPressed: (event) => { - event.accepted = false; - if (chatDocumentHandler.atLastLine) { - Message.contentModel.focusRow = root.index + 1 - } + event.accepted = true; + Message.contentModel.keyHelper.down(); } Keys.onTabPressed: (event) => { event.accepted = true; - chatDocumentHandler.tab(); + Message.contentModel.keyHelper.tab(); } Keys.onDeletePressed: (event) => { event.accepted = true; - chatDocumentHandler.deleteChar(); + Message.contentModel.keyHelper.deleteChar(); } Keys.onPressed: (event) => { if (event.key == Qt.Key_Backspace && cursorPosition == 0) { event.accepted = true; - chatDocumentHandler.backspace(); + Message.contentModel.keyHelper.backspace(); return; } event.accepted = false; @@ -111,11 +105,11 @@ TextEdit { Keys.onEnterPressed: (event) => { event.accepted = true; - chatDocumentHandler.insertReturn(); + Message.contentModel.keyHelper.insertReturn(); } Keys.onReturnPressed: (event) => { event.accepted = true; - chatDocumentHandler.insertReturn(); + Message.contentModel.keyHelper.insertReturn(); } onFocusChanged: if (focus && !root.currentFocus) { diff --git a/src/messagecontent/models/chatbarmessagecontentmodel.cpp b/src/messagecontent/models/chatbarmessagecontentmodel.cpp index 4fdd40458..74080ad59 100644 --- a/src/messagecontent/models/chatbarmessagecontentmodel.cpp +++ b/src/messagecontent/models/chatbarmessagecontentmodel.cpp @@ -6,20 +6,26 @@ #include #include "chatbarcache.h" -#include "chatdocumenthandler.h" +#include "chatkeyhelper.h" +#include "chatmarkdownhelper.h" +#include "chattextitemhelper.h" #include "enums/chatbartype.h" #include "enums/messagecomponenttype.h" #include "enums/richformat.h" #include "messagecontentmodel.h" -#include "qmltextitemwrapper.h" + +namespace +{ +constexpr auto TextItemKey = "chatTextItemHelper"_L1; +} ChatBarMessageContentModel::ChatBarMessageContentModel(QObject *parent) : MessageContentModel(parent) - , m_currentTextItem(new QmlTextItemWrapper(this)) , m_markdownHelper(new ChatMarkdownHelper(this)) + , m_keyHelper(new ChatKeyHelper(this)) { m_editableActive = true; - connectCurentTextItem(); + connectKeyHelper(); initializeModel(); connect(this, &ChatBarMessageContentModel::roomChanged, this, [this]() { @@ -53,17 +59,38 @@ ChatBarMessageContentModel::ChatBarMessageContentModel(QObject *parent) Q_EMIT focusRowChanged(); }); + connect(this, &ChatBarMessageContentModel::focusRowChanged, this, [this]() { + m_markdownHelper->setTextItem(focusedTextItem()); + m_keyHelper->setTextItem(focusedTextItem()); + }); + connect(this, &ChatBarMessageContentModel::roomChanged, this, [this]() { + for (const auto &component : m_components) { + if (const auto textItem = textItemForComponent(component)) { + textItem->setRoom(m_room); + } + } + }); + connect(this, &ChatBarMessageContentModel::typeChanged, this, [this]() { + for (const auto &component : m_components) { + if (const auto textItem = textItemForComponent(component)) { + textItem->setType(m_type); + } + } + }); + connect(m_markdownHelper, &ChatMarkdownHelper::unhandledBlockFormat, this, &ChatBarMessageContentModel::insertStyleAtCursor); } void ChatBarMessageContentModel::initializeModel() { beginInsertRows({}, rowCount(), rowCount()); - const auto documentHandler = new ChatDocumentHandler(); - connectHandler(documentHandler); + const auto textItem = new ChatTextItemHelper(this); + textItem->setRoom(m_room); + textItem->setType(m_type); + connectTextItem(textItem); m_components += MessageComponent{ .type = MessageComponentType::Text, .display = {}, - .attributes = {{"chatDocumentHandler"_L1, QVariant::fromValue(documentHandler)}}, + .attributes = {{TextItemKey, QVariant::fromValue(textItem)}}, }; m_currentFocusComponent = QPersistentModelIndex(index(0)); endInsertRows(); @@ -71,86 +98,6 @@ void ChatBarMessageContentModel::initializeModel() Q_EMIT focusRowChanged(); } -void ChatBarMessageContentModel::connectCurentTextItem() -{ - if (const auto docHandler = focusedDocumentHandler()) { - m_currentTextItem->setTextItem(docHandler->textItem()); - } - connect(this, &ChatBarMessageContentModel::focusRowChanged, this, [this]() { - if (const auto docHandler = focusedDocumentHandler()) { - m_currentTextItem->setTextItem(docHandler->textItem()); - m_markdownHelper->setTextItem(m_currentTextItem); - } - }); -} - -void ChatBarMessageContentModel::connectHandler(ChatDocumentHandler *handler) -{ - connect(handler, &ChatDocumentHandler::contentsChanged, this, &ChatBarMessageContentModel::updateCache); - connect(handler, &ChatDocumentHandler::unhandledBackspaceAtBeginning, this, [this](ChatDocumentHandler *handler) { - const auto index = indexForDocumentHandler(handler); - if (index.isValid()) { - if (index.row() > 0 && MessageComponentType::isFileType(m_components[index.row() - 1].type)) { - removeAttachment(); - } else if (m_components[index.row()].type == MessageComponentType::Code || m_components[index.row()].type == MessageComponentType::Quote) { - insertComponentAtCursor(MessageComponentType::Text); - } - } - }); - connect(handler, &ChatDocumentHandler::removeMe, this, [this](ChatDocumentHandler *handler) { - removeComponent(handler); - }); -} - -ChatDocumentHandler *ChatBarMessageContentModel::documentHandlerForComponent(const MessageComponent &component) const -{ - if (const auto chatDocumentHandler = qvariant_cast(component.attributes["chatDocumentHandler"_L1])) { - return chatDocumentHandler; - } - return nullptr; -} - -ChatDocumentHandler *ChatBarMessageContentModel::documentHandlerForIndex(const QModelIndex &index) const -{ - return documentHandlerForComponent(m_components[index.row()]); -} - -QModelIndex ChatBarMessageContentModel::indexForDocumentHandler(ChatDocumentHandler *handler) const -{ - for (auto it = m_components.begin(); it != m_components.end(); ++it) { - const auto currentIndex = index(it - m_components.begin()); - if (documentHandlerForIndex(currentIndex) == handler) { - return currentIndex; - } - } - return {}; -} - -void ChatBarMessageContentModel::updateDocumentHandlerRefs(const ComponentIt &it) -{ - if (it == m_components.end()) { - return; - } - - const auto handler = documentHandlerForComponent(*it); - if (!handler) { - return; - } - - if (it != m_components.begin()) { - if (const auto beforeHandler = documentHandlerForComponent(*(it - 1))) { - beforeHandler->setNextDocumentHandler(handler); - handler->setPreviousDocumentHandler(beforeHandler); - } - } - if (it + 1 != m_components.end()) { - if (const auto afterHandler = documentHandlerForComponent(*(it + 1))) { - afterHandler->setPreviousDocumentHandler(handler); - handler->setNextDocumentHandler(afterHandler); - } - } -} - ChatBarType::Type ChatBarMessageContentModel::type() const { return m_type; @@ -165,6 +112,46 @@ void ChatBarMessageContentModel::setType(ChatBarType::Type type) Q_EMIT typeChanged(); } +ChatKeyHelper *ChatBarMessageContentModel::keyHelper() const +{ + return m_keyHelper; +} + +void ChatBarMessageContentModel::connectKeyHelper() +{ + connect(m_keyHelper, &ChatKeyHelper::unhandledUp, this, [this]() { + setFocusRow(m_currentFocusComponent.row() - 1); + }); + connect(m_keyHelper, &ChatKeyHelper::unhandledDown, this, [this]() { + setFocusRow(m_currentFocusComponent.row() + 1); + }); + connect(m_keyHelper, &ChatKeyHelper::unhandledDelete, this, [this]() { + const auto currentRow = m_currentFocusComponent.row(); + if (currentRow < m_components.size() - 1) { + if (const auto nextTextItem = textItemForComponent(m_components[currentRow + 1])) { + focusedTextItem()->insertFragment(nextTextItem->takeFirstBlock(), ChatTextItemHelper::Cursor, true); + } + } + }); + connect(m_keyHelper, &ChatKeyHelper::unhandledBackspace, this, [this]() { + const auto currentRow = m_currentFocusComponent.row(); + if (currentRow > 0) { + const auto previousRow = currentRow - 1; + if (MessageComponentType::isFileType(m_components[previousRow].type)) { + removeAttachment(); + return; + } + if (const auto previousTextItem = textItemForComponent(m_components[previousRow])) { + previousTextItem->insertFragment(focusedTextItem()->takeFirstBlock(), ChatTextItemHelper::End, true); + return; + } + } + if (m_components[currentRow].type == MessageComponentType::Code || m_components[currentRow].type == MessageComponentType::Quote) { + insertComponentAtCursor(MessageComponentType::Text); + } + }); +} + int ChatBarMessageContentModel::focusRow() const { return m_currentFocusComponent.row(); @@ -199,39 +186,22 @@ void ChatBarMessageContentModel::setFocusIndex(const QModelIndex &index, bool mo void ChatBarMessageContentModel::focusCurrentComponent(const QModelIndex &previousIndex, bool down) { - const auto chatDocumentHandler = focusedDocumentHandler(); - if (!chatDocumentHandler) { + const auto textItem = focusedTextItem(); + if (!textItem) { return; } - chatDocumentHandler->setCursorFromDocumentHandler(documentHandlerForIndex(previousIndex), down, MessageComponentType::Quote ? 1 : 0); + textItem->setCursorFromTextItem(textItemForIndex(previousIndex), down, MessageComponentType::Quote ? 1 : 0); } void ChatBarMessageContentModel::refocusCurrentComponent() const { - const auto chatDocumentHandler = focusedDocumentHandler(); - if (!chatDocumentHandler) { + const auto textItem = focusedTextItem(); + if (!textItem) { return; } - chatDocumentHandler->textItem()->forceActiveFocus(); -} - -QmlTextItemWrapper *ChatBarMessageContentModel::currentTextItem() const -{ - return m_currentTextItem; -} - -ChatDocumentHandler *ChatBarMessageContentModel::focusedDocumentHandler() const -{ - if (!m_currentFocusComponent.isValid()) { - return nullptr; - } - - if (const auto chatDocumentHandler = documentHandlerForIndex(m_currentFocusComponent)) { - return chatDocumentHandler; - } - return nullptr; + textItem->forceActiveFocus(); } void ChatBarMessageContentModel::emitFocusChangeSignals() @@ -240,6 +210,53 @@ void ChatBarMessageContentModel::emitFocusChangeSignals() Q_EMIT dataChanged(index(0), index(rowCount() - 1), {CurrentFocusRole}); } +ChatTextItemHelper *ChatBarMessageContentModel::focusedTextItem() const +{ + if (!m_currentFocusComponent.isValid()) { + return nullptr; + } + if (const auto textItem = textItemForIndex(m_currentFocusComponent)) { + return textItem; + } + return nullptr; +} + +void ChatBarMessageContentModel::connectTextItem(ChatTextItemHelper *chattextitemhelper) +{ + connect(chattextitemhelper, &ChatTextItemHelper::contentsChanged, this, &ChatBarMessageContentModel::updateCache); + connect(chattextitemhelper, &ChatTextItemHelper::cleared, this, [this](ChatTextItemHelper *helper) { + removeComponent(helper); + }); +} + +ChatTextItemHelper *ChatBarMessageContentModel::textItemForComponent(const MessageComponent &component) const +{ + if (const auto textItem = qvariant_cast(component.attributes[TextItemKey])) { + return textItem; + } + return nullptr; +} + +ChatTextItemHelper *ChatBarMessageContentModel::textItemForIndex(const QModelIndex &index) const +{ + return textItemForComponent(m_components[index.row()]); +} + +QModelIndex ChatBarMessageContentModel::indexForTextItem(ChatTextItemHelper *textItem) const +{ + if (!textItem) { + return {}; + } + + for (auto it = m_components.begin(); it != m_components.end(); ++it) { + const auto currentIndex = index(it - m_components.begin()); + if (textItemForIndex(currentIndex) == textItem) { + return currentIndex; + } + } + return {}; +} + void ChatBarMessageContentModel::addAttachment(const QUrl &path) { if (m_type == ChatBarType::None || !m_room) { @@ -278,15 +295,16 @@ ChatBarMessageContentModel::insertComponent(int row, MessageComponentType::Type } if (MessageComponentType::isTextType(type)) { - const auto documentHandler = new ChatDocumentHandler(); - documentHandler->setInitialText(intialText); + const auto textItemWrapper = new ChatTextItemHelper(this); + textItemWrapper->setInitialText(intialText); + textItemWrapper->setRoom(m_room); + textItemWrapper->setType(m_type); if (type == MessageComponentType::Quote) { - documentHandler->setFixedStartChars(u"\""_s); - documentHandler->setFixedEndChars(u"\""_s); + textItemWrapper->setFixedChars(u"\""_s, u"\""_s); } - attributes.insert("chatDocumentHandler"_L1, QVariant::fromValue(documentHandler)); - connectHandler(documentHandler); + attributes.insert(TextItemKey, QVariant::fromValue(textItemWrapper)); + connectTextItem(textItemWrapper); } beginInsertRows({}, row, row); const auto it = m_components.insert(row, @@ -295,7 +313,6 @@ ChatBarMessageContentModel::insertComponent(int row, MessageComponentType::Type .display = {}, .attributes = attributes, }); - updateDocumentHandlerRefs(it); endInsertRows(); return it; } @@ -320,8 +337,8 @@ void ChatBarMessageContentModel::insertStyleAtCursor(RichFormat::Format style) void ChatBarMessageContentModel::insertComponentAtCursor(MessageComponentType::Type type) { if (m_components[m_currentFocusComponent.row()].type == type) { - if (type == MessageComponentType::Text && focusedDocumentHandler()) { - currentTextItem()->mergeFormatOnCursor(RichFormat::Paragraph); + if (type == MessageComponentType::Text && focusedTextItem()) { + focusedTextItem()->mergeFormatOnCursor(RichFormat::Paragraph); } return; } @@ -330,8 +347,8 @@ void ChatBarMessageContentModel::insertComponentAtCursor(MessageComponentType::T QTextDocumentFragment midFragment; std::optional afterFragment = std::nullopt; - if (const auto currentChatDocumentHandler = focusedDocumentHandler()) { - currentChatDocumentHandler->fillFragments(hasBefore, midFragment, afterFragment); + if (const auto currentTextItem = focusedTextItem()) { + currentTextItem->fillFragments(hasBefore, midFragment, afterFragment); } const auto currentType = m_components[m_currentFocusComponent.row()].type; @@ -343,8 +360,8 @@ void ChatBarMessageContentModel::insertComponentAtCursor(MessageComponentType::T const auto insertIt = insertComponent(insertRow, type); if (insertIt != m_components.end()) { - if (const auto insertChatDocumentHandler = documentHandlerForComponent(*insertIt)) { - insertChatDocumentHandler->insertFragment(midFragment); + if (const auto insertTextItem = textItemForComponent(*insertIt)) { + insertTextItem->insertFragment(midFragment); } m_currentFocusComponent = QPersistentModelIndex(index(insertIt - m_components.begin())); emitFocusChangeSignals(); @@ -353,8 +370,8 @@ void ChatBarMessageContentModel::insertComponentAtCursor(MessageComponentType::T if (afterFragment) { const auto afterIt = insertComponent(insertRow + 1, currentType); if (afterIt != m_components.end()) { - if (const auto afterChatDocumentHandler = documentHandlerForComponent(*afterIt)) { - afterChatDocumentHandler->insertFragment(*afterFragment); + if (const auto afterTextItem = textItemForComponent(*afterIt)) { + afterTextItem->insertFragment(*afterFragment); } } } @@ -405,21 +422,10 @@ ChatBarMessageContentModel::ComponentIt ChatBarMessageContentModel::removeCompon setFocusRow(newFocusRow); } - if (const auto chatDocumentHandler = documentHandlerForIndex(index(row))) { - const auto beforeHandler = chatDocumentHandler->previousDocumentHandler(); - const auto afterHandler = chatDocumentHandler->nextDocumentHandler(); - if (beforeHandler && afterHandler) { - beforeHandler->setNextDocumentHandler(afterHandler); - afterHandler->setPreviousDocumentHandler(beforeHandler); - } else if (beforeHandler) { - beforeHandler->setNextDocumentHandler(nullptr); - } else if (afterHandler) { - afterHandler->setPreviousDocumentHandler(nullptr); - } - - m_components[row].attributes.remove("chatDocumentHandler"_L1); - chatDocumentHandler->disconnect(this); - chatDocumentHandler->deleteLater(); + if (const auto textItem = textItemForIndex(index(row))) { + m_components[row].attributes.remove(TextItemKey); + textItem->disconnect(this); + textItem->deleteLater(); } it = m_components.erase(it); endRemoveRows(); @@ -427,9 +433,9 @@ ChatBarMessageContentModel::ComponentIt ChatBarMessageContentModel::removeCompon return it; } -void ChatBarMessageContentModel::removeComponent(ChatDocumentHandler *handler) +void ChatBarMessageContentModel::removeComponent(ChatTextItemHelper *textItem) { - const auto index = indexForDocumentHandler(handler); + const auto index = indexForTextItem(textItem); if (index.isValid()) { removeComponent(index.row()); } @@ -479,8 +485,8 @@ QString ChatBarMessageContentModel::messageText() const QString text; for (const auto &component : m_components) { if (MessageComponentType::isTextType(component.type)) { - if (const auto chatDocumentHandler = documentHandlerForComponent(component)) { - auto newText = chatDocumentHandler->htmlText(); + if (const auto textItem = textItemForComponent(component)) { + auto newText = textItem->markdownText(); if (component.type == MessageComponentType::Quote) { newText = formatQuote(newText); } else if (component.type == MessageComponentType::Code) { @@ -517,9 +523,9 @@ void ChatBarMessageContentModel::clearModel() { beginResetModel(); for (const auto &component : m_components) { - if (const auto chatDocumentHandler = documentHandlerForComponent(component)) { - chatDocumentHandler->disconnect(this); - chatDocumentHandler->deleteLater(); + if (const auto textItem = textItemForComponent(component)) { + textItem->disconnect(this); + textItem->deleteLater(); } } m_components.clear(); diff --git a/src/messagecontent/models/chatbarmessagecontentmodel.h b/src/messagecontent/models/chatbarmessagecontentmodel.h index 166b5044d..c7983a179 100644 --- a/src/messagecontent/models/chatbarmessagecontentmodel.h +++ b/src/messagecontent/models/chatbarmessagecontentmodel.h @@ -5,14 +5,14 @@ #include #include -#include -#include "chatdocumenthandler.h" +#include "chatkeyhelper.h" +#include "chatmarkdownhelper.h" +#include "chattextitemhelper.h" #include "enums/messagecomponenttype.h" #include "enums/richformat.h" #include "messagecomponent.h" #include "models/messagecontentmodel.h" -#include "qmltextitemwrapper.h" /** * @class ChatBarMessageContentModel @@ -42,31 +42,34 @@ class ChatBarMessageContentModel : public MessageContentModel /** * @brief The text item that the helper is interfacing with. * - * This is a QQuickItem that is a TextEdit (or inherited from) wrapped in a QmlTextItemWrapper + * This is a QQuickItem that is a TextEdit (or inherited from) wrapped in a ChatTextItemHelper * to provide easy access to properties and basic QTextDocument manipulation. * - * @sa TextEdit, QTextDocument, QmlTextItemWrapper + * @sa TextEdit, QTextDocument, ChatTextItemHelper */ - Q_PROPERTY(QmlTextItemWrapper *currentTextItem READ currentTextItem NOTIFY focusRowChanged) + Q_PROPERTY(ChatKeyHelper *keyHelper READ keyHelper CONSTANT) /** - * @brief The ChatDocumentHandler of the model component that currently has focus. + * @brief The text item that the helper is interfacing with. + * + * This is a QQuickItem that is a TextEdit (or inherited from) wrapped in a ChatTextItemHelper + * to provide easy access to properties and basic QTextDocument manipulation. + * + * @sa TextEdit, QTextDocument, ChatTextItemHelper */ - Q_PROPERTY(ChatDocumentHandler *focusedDocumentHandler READ focusedDocumentHandler NOTIFY focusRowChanged) + Q_PROPERTY(ChatTextItemHelper *focusedTextItem READ focusedTextItem NOTIFY focusRowChanged) public: explicit ChatBarMessageContentModel(QObject *parent = nullptr); ChatBarType::Type type() const; void setType(ChatBarType::Type type); - + ChatKeyHelper *keyHelper() const; int focusRow() const; MessageComponentType::Type focusType() const; Q_INVOKABLE void setFocusRow(int focusRow, bool mouse = false); - void setFocusIndex(const QModelIndex &index, bool mouse = false); Q_INVOKABLE void refocusCurrentComponent() const; - QmlTextItemWrapper *currentTextItem() const; - ChatDocumentHandler *focusedDocumentHandler() const; + ChatTextItemHelper *focusedTextItem() const; Q_INVOKABLE void insertStyleAtCursor(RichFormat::Format style); @@ -91,22 +94,22 @@ private: std::optional getReplyEventId() override; - QPointer m_currentTextItem; - void connectCurentTextItem(); - QPointer m_markdownHelper; + void setFocusIndex(const QModelIndex &index, bool mouse = false); + void focusCurrentComponent(const QModelIndex &previousIndex, bool down); + void emitFocusChangeSignals(); - void connectHandler(ChatDocumentHandler *handler); - ChatDocumentHandler *documentHandlerForComponent(const MessageComponent &component) const; - ChatDocumentHandler *documentHandlerForIndex(const QModelIndex &index) const; - QModelIndex indexForDocumentHandler(ChatDocumentHandler *handler) const; - void updateDocumentHandlerRefs(const ComponentIt &it); + void connectTextItem(ChatTextItemHelper *chattextitemhelper); + ChatTextItemHelper *textItemForComponent(const MessageComponent &component) const; + ChatTextItemHelper *textItemForIndex(const QModelIndex &index) const; + QModelIndex indexForTextItem(ChatTextItemHelper *textItem) const; + + QPointer m_markdownHelper; + QPointer m_keyHelper; + void connectKeyHelper(); ComponentIt insertComponent(int row, MessageComponentType::Type type, QVariantMap attributes = {}, const QString &intialText = {}); ComponentIt removeComponent(ComponentIt it); - void removeComponent(ChatDocumentHandler *handler); - - void focusCurrentComponent(const QModelIndex &previousIndex, bool down); - void emitFocusChangeSignals(); + void removeComponent(ChatTextItemHelper *textItem); void updateCache() const; QString messageText() const;