diff --git a/src/chatdocumenthandler.cpp b/src/chatdocumenthandler.cpp index b6d328d40..bf4f75bbb 100644 --- a/src/chatdocumenthandler.cpp +++ b/src/chatdocumenthandler.cpp @@ -90,6 +90,11 @@ public: }), mentions->end()); } + + QStringList suggestions(const QString &word) const + { + return checker->suggest(word); + } }; ChatDocumentHandler::ChatDocumentHandler(QObject *parent) @@ -263,6 +268,47 @@ void ChatDocumentHandler::complete(int index) } } +QStringList ChatDocumentHandler::getSuggestions(int mousePosition) +{ + QTextCursor cursorAtMouse(document()->textDocument()); + cursorAtMouse.setPosition(mousePosition); + + // Get the word under the (mouse-)cursor and see if it is misspelled. + // Don't include apostrophes at the start/end of the word in the selection. + QTextCursor wordSelectCursor(cursorAtMouse); + wordSelectCursor.clearSelection(); + wordSelectCursor.select(QTextCursor::WordUnderCursor); + m_selectedWord = wordSelectCursor.selectedText(); + + return m_highlighter->suggestions(m_selectedWord); +} + +bool ChatDocumentHandler::getActive() const +{ + return m_highlighter->settings.checkerEnabledByDefault(); +} + +bool ChatDocumentHandler::getIsWordIsMisspelled() const +{ + return !m_highlighter->errors.isEmpty(); +} + +QString ChatDocumentHandler::getWordUnderMouse() const +{ + return m_selectedWord; +} + +void ChatDocumentHandler::replaceWord(const QString &word) +{ + QTextCursor cursor(document()->textDocument()); + const auto &text = m_room->chatBoxText(); + + auto at = text.indexOf(m_highlighter->previousText); + cursor.setPosition(at); + cursor.setPosition(at + m_highlighter->previousText.length(), QTextCursor::KeepAnchor); + cursor.insertText(word); +} + CompletionModel *ChatDocumentHandler::completionModel() const { return m_completionModel; diff --git a/src/chatdocumenthandler.h b/src/chatdocumenthandler.h index 788610b9f..221044ac9 100644 --- a/src/chatdocumenthandler.h +++ b/src/chatdocumenthandler.h @@ -87,6 +87,10 @@ class ChatDocumentHandler : public QObject */ Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged) + Q_PROPERTY(bool active READ getActive NOTIFY cursorPositionChanged) + Q_PROPERTY(bool wordIsMisspelled READ getIsWordIsMisspelled NOTIFY cursorPositionChanged) + Q_PROPERTY(QString wordUnderMouse READ getWordUnderMouse NOTIFY cursorPositionChanged) + /** * @brief The current CompletionModel. * @@ -133,6 +137,12 @@ public: Q_INVOKABLE void complete(int index); + Q_INVOKABLE void replaceWord(const QString &word); + Q_INVOKABLE QStringList getSuggestions(int mousePosition); + bool getActive() const; + bool getIsWordIsMisspelled() const; + QString getWordUnderMouse() const; + void updateCompletions(); CompletionModel *completionModel() const; @@ -178,4 +188,6 @@ private: CompletionModel::AutoCompletionType m_completionType = CompletionModel::None; CompletionModel *m_completionModel = nullptr; + + QString m_selectedWord; }; diff --git a/src/main.cpp b/src/main.cpp index 28f1b5c9e..d9c198c68 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -285,6 +285,7 @@ int main(int argc, char *argv[]) qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatRoom", {}); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatConnection", {}); + qmlRegisterSingletonType(QUrl("qrc:/ContextMenu.qml"), "org.kde.neochat", 1, 0, "ContextMenu"); qRegisterMetaType("User*"); qRegisterMetaType("const User*"); qRegisterMetaType("const Quotient::User*"); diff --git a/src/qml/Component/ChatBox/ChatBar.qml b/src/qml/Component/ChatBox/ChatBar.qml index d86a0bf09..e598e482c 100644 --- a/src/qml/Component/ChatBox/ChatBar.qml +++ b/src/qml/Component/ChatBox/ChatBar.qml @@ -177,6 +177,18 @@ QQC2.Control { interval: 5000 } + TapHandler { + enabled: true + + acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus + acceptedButtons: Qt.LeftButton | Qt.RightButton + + // unfortunately, taphandler's pressed event only triggers when the press is lifted + // we need to use the longpress signal since it triggers when the button is first pressed + longPressThreshold: 0 + onLongPressed: ContextMenu.targetClick(point, textField, documentHandler, textField.positionAt(point.position.x, point.position.y)); + } + onTextChanged: { if (!repeatTimer.running && Config.typingNotifications) { var textExists = text.length > 0 diff --git a/src/qml/Component/ChatBox/ContextMenu.qml b/src/qml/Component/ChatBox/ContextMenu.qml new file mode 100644 index 000000000..64a8ce773 --- /dev/null +++ b/src/qml/Component/ChatBox/ContextMenu.qml @@ -0,0 +1,245 @@ +pragma Singleton +import QtQuick 2.6 +import QtQml 2.2 +import QtQuick.Controls 2.15 +import org.kde.kirigami 2.5 as Kirigami + +Menu { + id: contextMenu + + property Item target + property bool deselectWhenMenuClosed: true + property int restoredCursorPosition: 0 + property int restoredSelectionStart + property int restoredSelectionEnd + property bool persistentSelectionSetting + property var chatdocumenthandler: null + property var suggestions: [] + Component.onCompleted: persistentSelectionSetting = persistentSelectionSetting // break binding + + property var runOnMenuClose: () => {} + + parent: Overlay.overlay + + function storeCursorAndSelection() { + contextMenu.restoredCursorPosition = target.cursorPosition; + contextMenu.restoredSelectionStart = target.selectionStart; + contextMenu.restoredSelectionEnd = target.selectionEnd; + } + + // target is pressed with mouse + function targetClick(handlerPoint, newTarget, chatdocumenthandler, mousePosition) { + if (handlerPoint.pressedButtons === Qt.RightButton) { // only accept just right click + if (contextMenu.visible) { + deselectWhenMenuClosed = false; // don't deselect text if menu closed by right click on textfield + dismiss(); + } else { + contextMenu.target = newTarget; + contextMenu.target.persistentSelection = true; // persist selection when menu is opened + contextMenu.chatdocumenthandler = chatdocumenthandler; + contextMenu.suggestions = mousePosition ? chatdocumenthandler.getSuggestions(mousePosition) : []; + + storeCursorAndSelection(); + popup(contextMenu.target); + // slightly locate context menu away from mouse so no item is selected when menu is opened + x += 1 + y += 1 + } + } else { + dismiss(); + } + } + + // context menu keyboard key + function targetKeyPressed(event, newTarget) { + if (event.modifiers === Qt.NoModifier && event.key === Qt.Key_Menu) { + contextMenu.target = newTarget; + target.persistentSelection = true; // persist selection when menu is opened + storeCursorAndSelection(); + popup(contextMenu.target); + } + } + + readonly property bool targetIsPassword: target !== null && (target.echoMode === TextInput.PasswordEchoOnEdit || target.echoMode === TextInput.Password) + + onAboutToShow: { + if (Overlay.overlay) { + let tempZ = 0 + for (let i in Overlay.overlay.visibleChildren) { + tempZ = Math.max(tempZ, Overlay.overlay.visibleChildren[i].z) + } + z = tempZ + 1 + } + } + + // deal with whether or not text should be deselected + onClosed: { + // restore text field's original persistent selection setting + target.persistentSelection = persistentSelectionSetting + // deselect text field text if menu is closed not because of a right click on the text field + if (deselectWhenMenuClosed) { + target.deselect(); + } + deselectWhenMenuClosed = true; + + // restore cursor position + target.forceActiveFocus(); + target.cursorPosition = restoredCursorPosition; + target.select(restoredSelectionStart, restoredSelectionEnd); + + // run action, and free memory + runOnMenuClose(); + runOnMenuClose = () => {}; + } + + onOpened: { + runOnMenuClose = () => {}; + } + + Instantiator { + active: target !== null && !target.readOnly && chatdocumenthandler !== null && chatdocumenthandler.active && chatdocumenthandler.wordIsMisspelled + model: suggestions + delegate: MenuItem { + text: modelData + onClicked: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => chatdocumenthandler.replaceWord(modelData); + } + } + onObjectAdded: { + contextMenu.insertItem(0, object) + } + onObjectRemoved: contextMenu.removeItem(0) + } + + MenuItem { + visible: target !== null && !target.readOnly && chatdocumenthandler !== null && chatdocumenthandler.active && chatdocumenthandler.wordIsMisspelled && suggestions.length === 0 + action: Action { + text: chatdocumenthandler ? qsTr("No suggestions for \"%1\"").arg(chatdocumenthandler.wordUnderMouse) : '' + enabled: false + } + } + + MenuItem { + visible: target !== null && !target.readOnly && chatdocumenthandler !== null && chatdocumenthandler.active && chatdocumenthandler.wordIsMisspelled + action: Action { + text: chatdocumenthandler ? qsTr("Add \"%1\" to dictionary").arg(chatdocumenthandler.wordUnderMouse) : '' + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => chatdocumenthandler.addWordToDictionary(chatdocumenthandler.wordUnderMouse); + } + } + } + + MenuItem { + visible: target !== null && !target.readOnly && chatdocumenthandler !== null && chatdocumenthandler.active && chatdocumenthandler.wordIsMisspelled + action: Action { + text: qsTr("Ignore") + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => chatdocumenthandler.ignoreWord(chatdocumenthandler.wordUnderMouse); + } + } + } + + MenuSeparator { + visible: target !== null && !target.readOnly && ((chatdocumenthandler !== null && chatdocumenthandler.active && chatdocumenthandler.wordIsMisspelled)) + } + + MenuItem { + visible: target !== null && !target.readOnly && !targetIsPassword + action: Action { + icon.name: "edit-undo-symbolic" + text: qsTr("Undo") + shortcut: StandardKey.Undo + } + enabled: target !== null && target.canUndo + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => target.undo(); + } + } + MenuItem { + visible: target !== null && !target.readOnly && !targetIsPassword + action: Action { + icon.name: "edit-redo-symbolic" + text: qsTr("Redo") + shortcut: StandardKey.Redo + } + enabled: target !== null && target.canRedo + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => target.redo(); + } + } + MenuSeparator { + visible: target !== null && !target.readOnly && !targetIsPassword + } + MenuItem { + visible: target !== null && !target.readOnly && !targetIsPassword + action: Action { + icon.name: "edit-cut-symbolic" + text: qsTr("Cut") + shortcut: StandardKey.Cut + } + enabled: target !== null && target.selectedText + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => target.cut(); + } + } + MenuItem { + action: Action { + icon.name: "edit-copy-symbolic" + text: qsTr("Copy") + shortcut: StandardKey.Copy + } + enabled: target !== null && target.selectedText + visible: !targetIsPassword + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => target.copy(); + } + } + MenuItem { + visible: target !== null && !target.readOnly + action: Action { + icon.name: "edit-paste-symbolic" + text: qsTr("Paste") + shortcut: StandardKey.Paste + } + enabled: target !== null && target.canPaste + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => target.paste(); + } + } + MenuItem { + visible: target !== null && !target.readOnly + action: Action { + icon.name: "edit-delete-symbolic" + text: qsTr("Delete") + shortcut: StandardKey.Delete + } + enabled: target !== null && target.selectedText + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => target.remove(target.selectionStart, target.selectionEnd); + } + } + MenuSeparator { + visible: !targetIsPassword + } + MenuItem { + action: Action { + icon.name: "edit-select-all-symbolic" + text: qsTr("Select All") + shortcut: StandardKey.SelectAll + } + visible: !targetIsPassword + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = () => target.selectAll(); + } + } +} diff --git a/src/res.qrc b/src/res.qrc index d4ac028c3..11216b815 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -36,6 +36,7 @@ qml/Component/HoverActions.qml qml/Component/ChatBox/ChatBox.qml qml/Component/ChatBox/ChatBar.qml + qml/Component/ChatBox/ContextMenu.qml qml/Component/ChatBox/AttachmentPane.qml qml/Component/ChatBox/ReplyPane.qml qml/Component/ChatBox/CompletionMenu.qml