diff --git a/imports/NeoChat/Component/ChatTextInput.qml b/imports/NeoChat/Component/ChatTextInput.qml index 286dab1b7..de4bdf3bf 100644 --- a/imports/NeoChat/Component/ChatTextInput.qml +++ b/imports/NeoChat/Component/ChatTextInput.qml @@ -116,7 +116,8 @@ ToolBar { keyNavigationWraps: true delegate: Control { - property string autoCompleteText: modelData.displayName ?? modelData.unicode + property string autoCompleteText: modelData.displayName ? ("" + modelData.displayName + ":") : modelData.unicode + property string displayText: modelData.displayName ?? modelData.unicode property bool isEmoji: modelData.unicode != null readonly property bool highlighted: autoCompleteListView.currentIndex == index @@ -147,7 +148,7 @@ ToolBar { Layout.fillHeight: true visible: !isEmoji - text: autoCompleteText + text: displayText color: highlighted ? Kirigami.Theme.highlightTextColor : Kirigami.Theme.textColor font.underline: highlighted verticalAlignment: Text.AlignVCenter @@ -159,7 +160,7 @@ ToolBar { anchors.fill: parent onClicked: { autoCompleteListView.currentIndex = index - inputField.replaceAutoComplete(autoCompleteText) + documentHandler.replaceAutoComplete(autoCompleteText) } } } @@ -221,14 +222,24 @@ ToolBar { } TextArea { + id: inputField property real progress: 0 - property bool autoAppeared: false; + property bool autoAppeared: false + + ChatDocumentHandler { + id: documentHandler + document: inputField.textDocument + cursorPosition: inputField.cursorPosition + selectionStart: inputField.selectionStart + selectionEnd: inputField.selectionEnd + room: currentRoom ?? null + } Layout.fillWidth: true - id: inputField wrapMode: Text.Wrap + textFormat: TextEdit.RichText placeholderText: i18n("Write your message...") topPadding: 0 bottomPadding: 0 @@ -268,6 +279,11 @@ ToolBar { } Keys.onReturnPressed: { + if (isAutoCompleting) { + documentHandler.replaceAutoComplete(autoCompleteListView.currentItem.autoCompleteText) + isAutoCompleting = false; + return; + } if (event.modifiers & Qt.ShiftModifier) { insert(cursorPosition, "\n") } else { @@ -280,28 +296,28 @@ ToolBar { Keys.onEscapePressed: closeAll() - Keys.onBacktabPressed: if (isAutoCompleting) autoCompleteListView.decrementCurrentIndex() + Keys.onBacktabPressed: { + if (isAutoCompleting) { + autoCompleteListView.decrementCurrentIndex(); + } + } Keys.onTabPressed: { - if (isAutoCompleting && autoAppeared === false) { - autoAppeared = false; + if (!isAutoCompleting) { + return; + } + + // TODO detect moved cursor + + // ignore first time tab was clicked so that user can select + // first emoji/user + if (autoAppeared === false) { autoCompleteListView.incrementCurrentIndex() } else { autoAppeared = false; - autoCompleteBeginPosition = text.substring(0, cursorPosition).lastIndexOf(" ") + 1 - let autoCompletePrefix = text.substring(0, cursorPosition).split(" ").pop() - if (!autoCompletePrefix) return - if (autoCompletePrefix.startsWith(":")) { - autoCompleteBeginPosition = text.substring(0, cursorPosition).lastIndexOf(" ") + 1 - autoCompleteModel = emojiModel.filterModel(autoCompletePrefix) - } else { - autoCompleteModel = currentRoom.getUsers(autoCompletePrefix) - } - if (autoCompleteModel.length === 0) return - isAutoCompleting = true - autoCompleteEndPosition = cursorPosition } - replaceAutoComplete(autoCompleteListView.currentItem.autoCompleteText) + + documentHandler.replaceAutoComplete(autoCompleteListView.currentItem.autoCompleteText) } onTextChanged: { @@ -310,76 +326,35 @@ ToolBar { currentRoom.cachedInput = text autoAppeared = false; - if (cursorPosition !== autoCompleteBeginPosition && cursorPosition !== autoCompleteEndPosition) { - isAutoCompleting = false; - autoCompleteListView.currentIndex = 0; - } + const autocompletionInfo = documentHandler.getAutocompletionInfo(); - let autoCompletePrefix = text.substring(0, cursorPosition).split(" ").pop(); - if (!autoCompletePrefix) { + if (autocompletionInfo.type === ChatDocumentHandler.Ignore) { return; } - if (autoCompletePrefix.startsWith("@") || autoCompletePrefix.startsWith(":")) { - if (autoCompletePrefix.startsWith("@")) { - autoCompletePrefix = autoCompletePrefix.substring(1); - autoCompleteBeginPosition = text.substring(0, cursorPosition).lastIndexOf(" ") + 1 // 1 = space - autoCompleteModel = currentRoom.getUsers(autoCompletePrefix); - } else { - autoCompleteModel = emojiModel.filterModel(autoCompletePrefix); - } - if (autoCompleteModel.length === 0) { - return; - } - isAutoCompleting = true - autoAppeared = true; - autoCompleteEndPosition = cursorPosition + if (autocompletionInfo.type === ChatDocumentHandler.None) { + isAutoCompleting = false; + autoCompleteListView.currentIndex = 0; + return; } + + if (autocompletionInfo.type === ChatDocumentHandler.User) { + autoCompleteModel = currentRoom.getUsers(autocompletionInfo.keyword); + } else { + autoCompleteModel = emojiModel.filterModel(autocompletionInfo.keyword); + } + + if (autoCompleteModel.length === 0) { + return; + } + isAutoCompleting = true + autoAppeared = true; + autoCompleteEndPosition = cursorPosition } - function replaceAutoComplete(word) { - remove(autoCompleteBeginPosition, autoCompleteEndPosition) - autoCompleteEndPosition = autoCompleteBeginPosition + word.length - insert(cursorPosition, word) - } - - function postMessage(text) { - if(!currentRoom) { return } - - if (hasAttachment) { - currentRoom.uploadFile(attachmentPath, text) - clearAttachment() - return - } - - if (text.trim().length === 0) { return } - - var PREFIX_ME = '/me ' - var PREFIX_NOTICE = '/notice ' - var PREFIX_RAINBOW = '/rainbow ' - - var messageEventType = RoomMessageEvent.Text - - if (text.indexOf(PREFIX_RAINBOW) === 0) { - text = text.substr(PREFIX_RAINBOW.length) - - var parsedText = "" - var rainbowColor = ["#ff2b00", "#ff5500", "#ff8000", "#ffaa00", "#ffd500", "#ffff00", "#d4ff00", "#aaff00", "#80ff00", "#55ff00", "#2bff00", "#00ff00", "#00ff2b", "#00ff55", "#00ff80", "#00ffaa", "#00ffd5", "#00ffff", "#00d4ff", "#00aaff", "#007fff", "#0055ff", "#002bff", "#0000ff", "#2a00ff", "#5500ff", "#7f00ff", "#aa00ff", "#d400ff", "#ff00ff", "#ff00d4", "#ff00aa", "#ff0080", "#ff0055", "#ff002b", "#ff0000"] - for (var i = 0; i < text.length; i++) { - parsedText = parsedText + "" + text.charAt(i) + "" - } - currentRoom.postHtmlMessage(text, parsedText, RoomMessageEvent.Text, replyEventID) - return - } - - if (text.indexOf(PREFIX_ME) === 0) { - text = text.substr(PREFIX_ME.length) - messageEventType = RoomMessageEvent.Emote - } else if (text.indexOf(PREFIX_NOTICE) === 0) { - text = text.substr(PREFIX_NOTICE.length) - messageEventType = RoomMessageEvent.Notice - } - - currentRoom.postArbitaryMessage(text, messageEventType, replyEventID) + function postMessage() { + documentHandler.postMessage(attachmentPath, replyEventID); + clearAttachment(); + clear(); } } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ba91a889a..c1c2864b8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,6 +15,7 @@ add_executable(neochat main.cpp notificationsmanager.cpp sortfilterroomlistmodel.cpp + chatdocumenthandler.cpp ../res.qrc ) diff --git a/src/chatdocumenthandler.cpp b/src/chatdocumenthandler.cpp new file mode 100644 index 000000000..f1268d5ed --- /dev/null +++ b/src/chatdocumenthandler.cpp @@ -0,0 +1,231 @@ +#include "chatdocumenthandler.h" + +#include +#include +#include +#include +#include +#include + +#include "neochatroom.h" + +ChatDocumentHandler::ChatDocumentHandler(QObject *parent) + : QObject(parent) + , m_document(nullptr) + , m_cursorPosition(-1) +{ +} + +QQuickTextDocument *ChatDocumentHandler::document() const +{ + return m_document; +} + +void ChatDocumentHandler::setDocument(QQuickTextDocument *document) +{ + if (document == m_document) + return; + + if (m_document) + m_document->textDocument()->disconnect(this); + m_document = document; + Q_EMIT documentChanged(); +} + +int ChatDocumentHandler::cursorPosition() const +{ + return m_cursorPosition; +} + +void ChatDocumentHandler::setCursorPosition(int position) +{ + if (position == m_cursorPosition) + return; + + m_cursorPosition = position; + Q_EMIT cursorPositionChanged(); +} + + +int ChatDocumentHandler::selectionStart() const +{ + return m_selectionStart; +} + +void ChatDocumentHandler::setSelectionStart(int position) +{ + if (position == m_selectionStart) + return; + + m_selectionStart = position; + Q_EMIT selectionStartChanged(); +} + +int ChatDocumentHandler::selectionEnd() const +{ + return m_selectionEnd; +} + +void ChatDocumentHandler::setSelectionEnd(int position) +{ + if (position == m_selectionEnd) + return; + + m_selectionEnd = position; + Q_EMIT selectionEndChanged(); +} + +QTextCursor ChatDocumentHandler::textCursor() const +{ + QTextDocument *doc = textDocument(); + if (!doc) + return QTextCursor(); + + QTextCursor cursor = QTextCursor(doc); + if (m_selectionStart != m_selectionEnd) { + cursor.setPosition(m_selectionStart); + cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor); + } else { + cursor.setPosition(m_cursorPosition); + } + return cursor; +} + +QTextDocument *ChatDocumentHandler::textDocument() const +{ + if (!m_document) + return nullptr; + + return m_document->textDocument(); +} + +NeoChatRoom *ChatDocumentHandler::room() const +{ + return m_room; +} + +void ChatDocumentHandler::setRoom(NeoChatRoom *room) +{ + if (m_room == room) + return; + + m_room = room; + Q_EMIT roomChanged(); +} + +QVariantMap ChatDocumentHandler::getAutocompletionInfo() +{ + QTextCursor cursor = textCursor(); + + if (cursor.block().text() == m_lastState) { + // ignore change, it was caused by autocompletion + return QVariantMap { + {"type", AutoCompletionType::Ignore}, + }; + } + if (m_cursorPosition != m_autoCompleteBeginPosition + && m_cursorPosition != m_autoCompleteEndPosition) { + // we moved our cursor, so cancel autocompletion + } + + QString text = cursor.block().text(); + QString textBeforeCursor = text; + textBeforeCursor.truncate(m_cursorPosition); + + QString autoCompletePrefix = textBeforeCursor.section(" ", -1); + + if (autoCompletePrefix.isEmpty()) { + return QVariantMap { + {"type", AutoCompletionType::None}, + }; + } + + if (autoCompletePrefix.startsWith("@") || autoCompletePrefix.startsWith(":")) { + m_autoCompleteBeginPosition = textBeforeCursor.lastIndexOf(" ") + 1; // 1 == space + + if (autoCompletePrefix.startsWith("@")) { + autoCompletePrefix.remove(0, 1); + return QVariantMap { + {"keyword", autoCompletePrefix}, + {"type", AutoCompletionType::User}, + }; + } else { + return QVariantMap { + {"keyword", autoCompletePrefix}, + {"type", AutoCompletionType::Emoji}, + }; + } + } + + return QVariantMap { + {"type", AutoCompletionType::None}, + }; +} + +void ChatDocumentHandler::postMessage(const QString &attachementPath, const QString &replyEventId) const +{ + if (!m_room || !m_document) + return; + + QString cleanedText = m_document->textDocument()->toMarkdown(); + + cleanedText = cleanedText.trimmed(); + + if (attachementPath.length() > 0) { + m_room->uploadFile(attachementPath, cleanedText); + } + + if (cleanedText.length() == 0) + return; + + auto messageEventType = RoomMessageEvent::MsgType::Text; + + const QString rainbowPrefix = QStringLiteral("/rainbow "); + const QString mePrefix = QStringLiteral("/me "); + const QString noticePrefix = QStringLiteral("/notice "); + + if (cleanedText.indexOf(rainbowPrefix) == 0) { + cleanedText = cleanedText.remove(0, rainbowPrefix.length()); + QString rainbowText; + QStringList rainbowColors { "#ff2b00", "#ff5500", "#ff8000", "#ffaa00", "#ffd500", "#ffff00", "#d4ff00", "#aaff00", "#80ff00", "#55ff00", "#2bff00", "#00ff00", "#00ff2b", "#00ff55", "#00ff80", "#00ffaa", "#00ffd5", "#00ffff", "#00d4ff", "#00aaff", "#007fff", "#0055ff", "#002bff", "#0000ff", "#2a00ff", "#5500ff", "#7f00ff", "#aa00ff", "#d400ff", "#ff00ff", "#ff00d4", "#ff00aa", "#ff0080", "#ff0055", "#ff002b", "#ff0000" }; + + for (int i = 0; i < cleanedText.length(); i++) { + rainbowText = rainbowText % QStringLiteral("" % cleanedText.at(i) % ""; + } + m_room->postHtmlMessage(cleanedText, rainbowText, messageEventType, replyEventId); + return; + } + + if (cleanedText.indexOf(mePrefix) == 0) { + cleanedText = cleanedText.remove(0, mePrefix.length()); + messageEventType = RoomMessageEvent::MsgType::Emote; + } else if (cleanedText.indexOf(noticePrefix) == 0) { + cleanedText = cleanedText.remove(0, noticePrefix.length()); + messageEventType = RoomMessageEvent::MsgType::Notice; + } + m_room->postArbitaryMessage(cleanedText, messageEventType, replyEventId); +} + +void ChatDocumentHandler::replaceAutoComplete(const QString &word) +{ + QTextCursor cursor = textCursor(); + if (cursor.block().text() == m_lastState) { + m_document->textDocument()->undo(); + } + cursor.beginEditBlock(); + cursor.select(QTextCursor::WordUnderCursor); + cursor.removeSelectedText(); + cursor.deletePreviousChar(); + while (!cursor.atBlockStart()) { + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + + if (cursor.selectedText() == " ") { + cursor.movePosition(QTextCursor::NextCharacter); + break; + } + } + + cursor.insertHtml(word); + m_lastState = cursor.block().text(); + cursor.endEditBlock(); +} diff --git a/src/chatdocumenthandler.h b/src/chatdocumenthandler.h new file mode 100644 index 000000000..679c7b76d --- /dev/null +++ b/src/chatdocumenthandler.h @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2020 Carl Schwan + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include +#include + +class QTextDocument; +class QQuickTextDocument; +class NeoChatRoom; + +class ChatDocumentHandler : public QObject +{ + Q_OBJECT + Q_PROPERTY(QQuickTextDocument *document READ document WRITE setDocument NOTIFY documentChanged) + Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged) + Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged) + Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged) + + Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + +public: + enum AutoCompletionType { + User, + Emoji, + None, + Ignore, + }; + Q_ENUM(AutoCompletionType) + + explicit ChatDocumentHandler(QObject *object = nullptr); + + QQuickTextDocument *document() const; + void setDocument(QQuickTextDocument *document); + + int cursorPosition() const; + void setCursorPosition(int position); + + int selectionStart() const; + void setSelectionStart(int position); + + int selectionEnd() const; + void setSelectionEnd(int position); + + NeoChatRoom *room() const; + void setRoom(NeoChatRoom *room); + + Q_INVOKABLE void postMessage(const QString &attachmentPath, const QString &replyEventId) const; + + /// This function will look at the current QTextCursor and determine if there + /// is the posibility to autocomplete it. + Q_INVOKABLE QVariantMap getAutocompletionInfo(); + Q_INVOKABLE void replaceAutoComplete(const QString &word); + +Q_SIGNALS: + void documentChanged(); + void cursorPositionChanged(); + void selectionStartChanged(); + void selectionEndChanged(); + void roomChanged(); + +private: + QTextCursor textCursor() const; + QTextDocument *textDocument() const; + + QQuickTextDocument *m_document; + + NeoChatRoom *m_room; + + int m_cursorPosition; + int m_selectionStart; + int m_selectionEnd; + + int m_autoCompleteBeginPosition = -1; + int m_autoCompleteEndPosition = -1; + + QString m_lastState; +}; + +Q_DECLARE_METATYPE(ChatDocumentHandler::AutoCompletionType); diff --git a/src/main.cpp b/src/main.cpp index 521a43f79..1b7dee376 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -40,6 +40,7 @@ #include "userdirectorylistmodel.h" #include "userlistmodel.h" #include "neochatconfig.h" +#include "chatdocumenthandler.h" using namespace Quotient; @@ -81,6 +82,7 @@ int main(int argc, char *argv[]) qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "Clipboard", &clipboard); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "Config", config); qmlRegisterType("org.kde.neochat", 1, 0, "AccountListModel"); + qmlRegisterType("org.kde.neochat", 1, 0, "ChatDocumentHandler"); qmlRegisterType("org.kde.neochat", 1, 0, "RoomListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageEventModel");