diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index 37a73a785..a36eaf90a 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -35,7 +35,7 @@ QQC2.Control { required property NeoChatConnection connection - onActiveFocusChanged: textField.forceActiveFocus() + onActiveFocusChanged: chatContentView.itemAt(contentModel.index(contentModel.focusRow, 0)).forceActiveFocus() onCurrentRoomChanged: { _private.chatBarCache = currentRoom.mainCache @@ -75,6 +75,9 @@ QQC2.Control { Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.inherit: false + Message.room: root.currentRoom + Message.contentModel: contentModel + background: Rectangle { color: Kirigami.Theme.backgroundColor Kirigami.Separator { @@ -84,233 +87,39 @@ QQC2.Control { } } + height: Math.max(Math.min(chatScrollView.contentHeight + bottomPadding + topPadding, Kirigami.Units.gridUnit * 10), Kirigami.Units.gridUnit * 5) leftPadding: rightPadding rightPadding: (root.width - chatBarSizeHelper.availableWidth) / 2 - topPadding: 0 - bottomPadding: 0 + topPadding: Kirigami.Units.smallSpacing + bottomPadding: Kirigami.Units.smallSpacing - contentItem: ColumnLayout { - spacing: 0 - Item { - // Required to adjust for the top separator - Layout.preferredHeight: 1 - Layout.fillWidth: true - } - Loader { - id: replyLoader - - Layout.fillWidth: true - Layout.margins: Kirigami.Units.largeSpacing - Layout.preferredHeight: active ? (item as Item).implicitHeight : 0 - - active: visible - visible: root.currentRoom.mainCache.replyId.length > 0 - sourceComponent: replyPane - } - RowLayout { - visible: replyLoader.visible && !root.currentRoom.mainCache.relationAuthorIsPresent + contentItem: QQC2.ScrollView { + id: chatScrollView + ColumnLayout { spacing: Kirigami.Units.smallSpacing - Kirigami.Icon { - source: "help-hint-symbolic" - color: Kirigami.Theme.disabledTextColor - - Layout.preferredWidth: Kirigami.Units.iconSizes.small - Layout.preferredHeight: Kirigami.Units.iconSizes.small - } - QQC2.Label { - text: i18nc("@info", "The user you're replying to has left the room, and can't be notified.") - color: Kirigami.Theme.disabledTextColor - } - } - Loader { - id: attachLoader - - Layout.fillWidth: true - Layout.margins: Kirigami.Units.largeSpacing - Layout.preferredHeight: active ? (item as Item).implicitHeight : 0 - - active: visible - visible: root.currentRoom.mainCache.attachmentPath.length > 0 - sourceComponent: attachmentPane - } - RowLayout { - QQC2.ScrollView { - id: chatBarScrollView - Layout.topMargin: Kirigami.Units.smallSpacing - Layout.bottomMargin: Kirigami.Units.smallSpacing - Layout.leftMargin: Kirigami.Units.largeSpacing - Layout.rightMargin: Kirigami.Units.largeSpacing - - Layout.fillWidth: true - Layout.maximumHeight: Kirigami.Units.gridUnit * 8 - Layout.minimumHeight: Kirigami.Units.gridUnit * 3 - - // HACK: This is to stop the ScrollBar flickering on and off as the height is increased - QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded - - Behavior on implicitHeight { - NumberAnimation { - id: chatBarHeightAnimation - duration: Kirigami.Units.shortDuration - easing.type: Easing.InOutCubic - } + Repeater { + id: chatContentView + model: ChatBarMessageContentModel { + id: contentModel + type: ChatBarType.Room + room: root.currentRoom } - QQC2.TextArea { - id: textField - - placeholderText: root.currentRoom.usesEncryption ? i18nc("@placeholder", "Send an encrypted message…") : root.currentRoom.mainCache.attachmentPath.length > 0 ? i18nc("@placeholder", "Set an attachment caption…") : i18nc("@placeholder", "Send a message…") - verticalAlignment: TextEdit.AlignVCenter - wrapMode: TextEdit.Wrap - persistentSelection: true - - Accessible.description: placeholderText - - Kirigami.SpellCheck.enabled: false - - Timer { - id: repeatTimer - interval: 5000 - } - - onTextChanged: { - if (!repeatTimer.running && NeoChatConfig.typingNotifications) { - var textExists = text.length > 0; - root.currentRoom.sendTypingNotification(textExists); - textExists ? repeatTimer.start() : repeatTimer.stop(); - } - } - onSelectedTextChanged: { - if (selectedText.length > 0) { - quickFormatBar.selectionStart = selectionStart; - quickFormatBar.selectionEnd = selectionEnd; - quickFormatBar.open(); - } else if (quickFormatBar.visible) { - quickFormatBar.close(); - } - } - - QuickFormatBar { - id: quickFormatBar - - x: textField.cursorRectangle.x - y: textField.cursorRectangle.y - height - - onFormattingSelected: (format, selectionStart, selectionEnd) => _private.formatText(format, selectionStart, selectionEnd) - } - - Keys.onEnterPressed: event => { - const controlIsPressed = event.modifiers & Qt.ControlModifier; - if (completionMenu.visible) { - completionMenu.complete(); - } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile || NeoChatConfig.sendMessageWith === 1 && !controlIsPressed || NeoChatConfig.sendMessageWith === 0 && controlIsPressed) { - textField.insert(cursorPosition, "\n"); - } else if (NeoChatConfig.sendMessageWith === 0 && !controlIsPressed || NeoChatConfig.sendMessageWith === 1 && controlIsPressed) { - _private.postMessage(); - } - } - Keys.onReturnPressed: event => { - const controlIsPressed = event.modifiers & Qt.ControlModifier; - if (completionMenu.visible) { - completionMenu.complete(); - } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile || NeoChatConfig.sendMessageWith === 1 && !controlIsPressed || NeoChatConfig.sendMessageWith === 0 && controlIsPressed) { - textField.insert(cursorPosition, "\n"); - } else if (NeoChatConfig.sendMessageWith === 0 && !controlIsPressed || NeoChatConfig.sendMessageWith === 1 && controlIsPressed) { - _private.postMessage(); - } - } - Keys.onTabPressed: { - if (completionMenu.visible) { - completionMenu.complete(); - } else { - contextDrawer.handle.children[0].forceActiveFocus() - } - } - Keys.onPressed: event => { - if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { - event.accepted = _private.pasteImage(); - } else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) { - root.currentRoom.replyLastMessage(); - } else if (event.key === Qt.Key_Up && textField.text.length === 0) { - root.currentRoom.editLastMessage(); - } else if (event.key === Qt.Key_Up && completionMenu.visible) { - completionMenu.decrementIndex(); - } else if (event.key === Qt.Key_Down && completionMenu.visible) { - completionMenu.incrementIndex(); - } else if (event.key === Qt.Key_Backspace || event.key === Qt.Key_Delete) { - if (textField.text == selectedText || textField.text.length <= 1) { - root.currentRoom.sendTypingNotification(false); - repeatTimer.stop(); - } - if (quickFormatBar.visible && selectedText.length > 0) { - quickFormatBar.close(); - } - } else if (event.key === Qt.Key_Escape && completionMenu.visible) { - completionMenu.close(); - } - } - Keys.onShortcutOverride: event => { - if ((_private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0) && event.key === Qt.Key_Escape) { - _private.chatBarCache.attachmentPath = ""; - _private.chatBarCache.replyId = ""; - _private.chatBarCache.threadId = ""; - event.accepted = true; - } - } - - background: MouseArea { - acceptedButtons: Qt.NoButton - cursorShape: Qt.IBeamCursor - z: 1 - } - } + delegate: MessageComponentChooser {} } - RowLayout { - id: actionsRow - spacing: 0 - Layout.alignment: Qt.AlignBottom - Layout.bottomMargin: Kirigami.Units.smallSpacing * 4 + RichEditBar { + id: richEditBar + maxAvailableWidth: chatBarSizeHelper.availableWidth - Kirigami.Units.largeSpacing * 2 - Repeater { - model: root.actions - delegate: QQC2.ToolButton { - id: actionDelegate - required property BusyAction modelData - icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source) - onClicked: if (!pieProgress.visible) { - modelData.trigger() - } + room: root.currentRoom + contentModel: chatContentView.model - padding: Kirigami.Units.smallSpacing - - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: modelData.tooltip - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - - PieProgressBar { - id: pieProgress - anchors.fill: parent - visible: actionDelegate.modelData.isBusy - progress: root.currentRoom.fileUploadingProgress - } - } - } + onClicked: contentModel.refocusCurrentComponent() } } - RichEditBar { - id: richEditBar - Layout.maximumWidth: chatBarSizeHelper.availableWidth - Kirigami.Units.largeSpacing * 2 - Layout.margins: Kirigami.Units.largeSpacing - Layout.alignment:Qt.AlignHCenter - maxAvailableWidth: chatBarSizeHelper.availableWidth - Kirigami.Units.largeSpacing * 2 - - room: root.currentRoom - documentHandler: documentHandler - - onRequestPostMessage: _private.postMessage() - } } + LibNeoChat.DelegateSizeHelper { id: chatBarSizeHelper parentItem: root @@ -321,49 +130,6 @@ QQC2.Control { maxWidth: NeoChatConfig.compactLayout ? root.width - Kirigami.Units.largeSpacing * 2 : Kirigami.Units.gridUnit * 60 } - Component { - id: replyPane - Item { - implicitHeight: replyComponent.implicitHeight - ReplyComponent { - id: replyComponent - replyContentModel: ContentProvider.contentModelForEvent(root.currentRoom, _private.chatBarCache.replyId, true) - Message.maxContentWidth: (replyLoader.item as Item).width - - // When the user replies to a message and the preview is loaded, make sure the text field is focused again - Component.onCompleted: textField.forceActiveFocus(Qt.OtherFocusReason) - } - QQC2.Button { - id: cancelButton - - anchors.top: parent.top - anchors.right: parent.right - - display: QQC2.AbstractButton.IconOnly - text: i18nc("@action:button", "Cancel reply") - icon.name: "dialog-close" - onClicked: { - _private.chatBarCache.replyId = ""; - _private.chatBarCache.attachmentPath = ""; - } - QQC2.ToolTip.text: text - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - } - } - Component { - id: attachmentPane - AttachmentPane { - attachmentPath: _private.chatBarCache.attachmentPath - - onAttachmentCancelled: { - _private.chatBarCache.attachmentPath = ""; - root.forceActiveFocus(); - } - } - } - QtObject { id: _private property ChatBarCache chatBarCache @@ -371,53 +137,6 @@ QQC2.Control { richEditBar.chatBarCache = chatBarCache } - function postMessage() { - _private.chatBarCache.postMessage(); - repeatTimer.stop(); - textField.clear(); - } - - function formatText(format, selectionStart, selectionEnd) { - let index = textField.cursorPosition; - - /* - * There cannot be white space at the beginning or end of the string for the - * formatting to work so move the sectionStart and sectionEnd markers past any whitespace. - */ - let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart); - if (innerText.charAt(innerText.length - 1) === " ") { - let trimmedRightString = innerText.replace(/\s*$/, ""); - let trimDifference = innerText.length - trimmedRightString.length; - selectionEnd -= trimDifference; - } - if (innerText.charAt(0) === " ") { - let trimmedLeftString = innerText.replace(/^\s*/, ""); - let trimDifference = innerText.length - trimmedLeftString.length; - selectionStart = selectionStart + trimDifference; - } - let startText = textField.text.substr(0, selectionStart); - // Needs updating with the new selectionStart and selectionEnd with white space trimmed. - innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart); - let endText = textField.text.substr(selectionEnd); - textField.text = ""; - textField.text = startText + format.start + innerText + format.end + format.extra + endText; - - /* - * Put the cursor where it was when the popup was opened accounting for the - * new markup. - * - * The exception is for a hyperlink where it is placed ready to start typing - * the url. - */ - if (format.extra !== "") { - textField.cursorPosition = selectionEnd + format.start.length + format.end.length; - } else if (index == selectionStart) { - textField.cursorPosition = index; - } else { - textField.cursorPosition = index + format.start.length + format.end.length; - } - } - function pasteImage() { let localPath = Clipboard.saveImage(); if (localPath.length === 0) { @@ -428,16 +147,9 @@ QQC2.Control { } } - ChatDocumentHandler { - id: documentHandler - type: ChatBarType.Room - textItem: textField - room: root.currentRoom - } - CompletionMenu { id: completionMenu - chatDocumentHandler: documentHandler + chatDocumentHandler: contentModel.focusedDocumentHandler connection: root.connection x: 1 diff --git a/src/chatbar/CompletionMenu.qml b/src/chatbar/CompletionMenu.qml index d8c4f86b8..539de6ecf 100644 --- a/src/chatbar/CompletionMenu.qml +++ b/src/chatbar/CompletionMenu.qml @@ -18,6 +18,10 @@ QQC2.Popup { required property NeoChatConnection connection required property var chatDocumentHandler + onChatDocumentHandlerChanged: if (chatDocumentHandler) { + chatDocumentHandler.completionModel.roomListModel = RoomManager.roomListModel; + chatDocumentHandler.completionModel.userListModel = RoomManager.userListModel; + } visible: completions.count > 0 @@ -25,11 +29,6 @@ QQC2.Popup { root.open(); } - Component.onCompleted: { - chatDocumentHandler.completionModel.roomListModel = RoomManager.roomListModel; - chatDocumentHandler.completionModel.userListModel = RoomManager.userListModel; - } - function incrementIndex() { completions.incrementCurrentIndex(); } diff --git a/src/chatbar/RichEditBar.qml b/src/chatbar/RichEditBar.qml index 5291c4c2c..32f27ef4f 100644 --- a/src/chatbar/RichEditBar.qml +++ b/src/chatbar/RichEditBar.qml @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2025 James Graham // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL +import QtCore import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts @@ -8,6 +9,7 @@ import QtQuick.Layouts import org.kde.kirigami as Kirigami import org.kde.neochat.libneochat as LibNeoChat +import org.kde.neochat.messagecontent as MessageContent QQC2.ToolBar { id: root @@ -19,13 +21,23 @@ QQC2.ToolBar { property LibNeoChat.ChatBarCache chatBarCache - required property LibNeoChat.ChatDocumentHandler documentHandler + required property MessageContent.ChatBarMessageContentModel contentModel + readonly property LibNeoChat.ChatDocumentHandler focusedDocumentHandler: contentModel.focusedDocumentHandler + + Connections { + target: contentModel + + function onFocusRowChanged() { + console.warn("focus changed", contentModel.focusRow, contentModel.focusType) + } + } required property real maxAvailableWidth readonly property real uncompressedImplicitWidth: textFormatRow.implicitWidth + listRow.implicitWidth + styleButton.implicitWidth + + codeButton.implicitWidth + emojiButton.implicitWidth + linkButton.implicitWidth + sendRow.implicitWidth + @@ -36,6 +48,7 @@ QQC2.ToolBar { readonly property real listCompressedImplicitWidth: textFormatRow.implicitWidth + compressedListButton.implicitWidth + styleButton.implicitWidth + + codeButton.implicitWidth + emojiButton.implicitWidth + linkButton.implicitWidth + sendRow.implicitWidth + @@ -46,6 +59,7 @@ QQC2.ToolBar { readonly property real textFormatCompressedImplicitWidth: compressedTextFormatButton.implicitWidth + compressedListButton.implicitWidth + styleButton.implicitWidth + + codeButton.implicitWidth + emojiButton.implicitWidth + linkButton.implicitWidth + sendRow.implicitWidth + @@ -53,7 +67,7 @@ QQC2.ToolBar { buttonRow.spacing * 9 + 3 - signal requestPostMessage + signal clicked RowLayout { id: buttonRow @@ -67,11 +81,15 @@ QQC2.ToolBar { onActivated: boldButton.clicked() } icon.name: "format-text-bold" + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code text: i18nc("@action:button", "Bold") display: QQC2.AbstractButton.IconOnly checkable: true - checked: root.documentHandler.bold - onClicked: root.documentHandler.bold = checked; + checked: root.focusedDocumentHandler.bold + onClicked: { + root.focusedDocumentHandler.bold = checked; + root.clicked() + } QQC2.ToolTip.text: text QQC2.ToolTip.visible: hovered @@ -84,11 +102,15 @@ QQC2.ToolBar { onActivated: italicButton.clicked() } icon.name: "format-text-italic" + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code text: i18nc("@action:button", "Italic") display: QQC2.AbstractButton.IconOnly checkable: true - checked: root.documentHandler.italic - onClicked: root.documentHandler.italic = checked; + checked: root.focusedDocumentHandler.italic + onClicked: { + root.focusedDocumentHandler.italic = checked; + root.clicked() + } QQC2.ToolTip.text: text QQC2.ToolTip.visible: hovered @@ -101,11 +123,15 @@ QQC2.ToolBar { onActivated: underlineButton.clicked() } icon.name: "format-text-underline" + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code text: i18nc("@action:button", "Underline") display: QQC2.AbstractButton.IconOnly checkable: true - checked: root.documentHandler.underline - onClicked: root.documentHandler.underline = checked; + checked: root.focusedDocumentHandler.underline + onClicked: { + root.focusedDocumentHandler.underline = checked; + root.clicked(); + } QQC2.ToolTip.text: text QQC2.ToolTip.visible: hovered @@ -113,11 +139,15 @@ QQC2.ToolBar { } QQC2.ToolButton { icon.name: "format-text-strikethrough" + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code text: i18nc("@action:button", "Strikethrough") display: QQC2.AbstractButton.IconOnly checkable: true - checked: root.documentHandler.strikethrough - onClicked: root.documentHandler.strikethrough = checked; + checked: root.focusedDocumentHandler.strikethrough + onClicked: { + root.focusedDocumentHandler.strikethrough = checked; + root.clicked() + } QQC2.ToolTip.text: text QQC2.ToolTip.visible: hovered @@ -128,6 +158,7 @@ QQC2.ToolBar { id: compressedTextFormatButton visible: root.maxAvailableWidth < root.listCompressedImplicitWidth icon.name: "dialog-text-and-font" + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code text: i18nc("@action:button", "Format Text") display: QQC2.AbstractButton.IconOnly checkable: true @@ -144,29 +175,41 @@ QQC2.ToolBar { icon.name: "format-text-bold" text: i18nc("@action:button", "Bold") checkable: true - checked: root.documentHandler.bold - onTriggered: root.documentHandler.bold = checked; + checked: root.focusedDocumentHandler.bold + onTriggered: { + root.focusedDocumentHandler.bold = checked; + root.clicked(); + } } QQC2.MenuItem { icon.name: "format-text-italic" text: i18nc("@action:button", "Italic") checkable: true - checked: root.documentHandler.italic - onTriggered: root.documentHandler.italic = checked; + checked: root.focusedDocumentHandler.italic + onTriggered: { + root.focusedDocumentHandler.italic = checked; + root.clicked(); + } } QQC2.MenuItem { icon.name: "format-text-underline" text: i18nc("@action:button", "Underline") checkable: true - checked: root.documentHandler.underline - onTriggered: root.documentHandler.underline = checked; + checked: root.focusedDocumentHandler.underline + onTriggered: { + root.focusedDocumentHandler.underline = checked; + root.clicked(); + } } QQC2.MenuItem { icon.name: "format-text-strikethrough" text: i18nc("@action:button", "Strikethrough") checkable: true - checked: root.documentHandler.strikethrough - onTriggered: root.documentHandler.strikethrough = checked; + checked: root.focusedDocumentHandler.strikethrough + onTriggered: { + root.focusedDocumentHandler.strikethrough = checked; + root.clicked(); + } } } @@ -183,12 +226,14 @@ QQC2.ToolBar { visible: root.maxAvailableWidth > root.uncompressedImplicitWidth QQC2.ToolButton { icon.name: "format-list-unordered" + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code text: i18nc("@action:button", "Unordered List") display: QQC2.AbstractButton.IconOnly checkable: true - checked: root.documentHandler.currentListStyle === 1 + checked: root.focusedDocumentHandler.currentListStyle === 1 onClicked: { - root.documentHandler.setListStyle(root.documentHandler.currentListStyle === 1 ? 0 : 1) + root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 1 ? 0 : 1); + root.clicked(); } QQC2.ToolTip.text: text @@ -197,11 +242,15 @@ QQC2.ToolBar { } QQC2.ToolButton { icon.name: "format-list-ordered" + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code text: i18nc("@action:button", "Ordered List") display: QQC2.AbstractButton.IconOnly checkable: true - checked: root.documentHandler.currentListStyle === 4 - onClicked: root.documentHandler.setListStyle(root.documentHandler.currentListStyle === 4 ? 0 : 4) + checked: root.focusedDocumentHandler.currentListStyle === 4 + onClicked: { + root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 4 ? 0 : 4); + root.clicked(); + } QQC2.ToolTip.text: text QQC2.ToolTip.visible: hovered @@ -213,7 +262,8 @@ QQC2.ToolBar { text: i18nc("@action:button", "Increase List Level") display: QQC2.AbstractButton.IconOnly onClicked: { - root.documentHandler.indentListMore(); + root.focusedDocumentHandler.indentListMore(); + root.clicked(); } QQC2.ToolTip.text: text @@ -226,7 +276,8 @@ QQC2.ToolBar { text: i18nc("@action:button", "Decrease List Level") display: QQC2.AbstractButton.IconOnly onClicked: { - root.documentHandler.indentListLess(); + root.focusedDocumentHandler.indentListLess(); + root.clicked(); } QQC2.ToolTip.text: text @@ -236,6 +287,7 @@ QQC2.ToolBar { } QQC2.ToolButton { id: compressedListButton + enabled: root.contentModel.focusType !== LibNeoChat.MessageComponentType.Code visible: root.maxAvailableWidth < root.uncompressedImplicitWidth icon.name: "format-list-unordered" text: i18nc("@action:button", "List Style") @@ -253,22 +305,34 @@ QQC2.ToolBar { QQC2.MenuItem { icon.name: "format-list-unordered" text: i18nc("@action:button", "Unordered List") - onTriggered: root.documentHandler.setListStyle(root.documentHandler.currentListStyle === 1 ? 0 : 1); + onTriggered: { + root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 1 ? 0 : 1); + root.clicked(); + } } QQC2.MenuItem { icon.name: "format-list-ordered" text: i18nc("@action:button", "Ordered List") - onTriggered: root.documentHandler.setListStyle(root.documentHandler.currentListStyle === 4 ? 0 : 4); + onTriggered: { + root.focusedDocumentHandler.setListStyle(root.focusedDocumentHandler.currentListStyle === 4 ? 0 : 4); + root.clicked(); + } } QQC2.MenuItem { icon.name: "format-indent-more" text: i18nc("@action:button", "Increase List Level") - onTriggered: root.documentHandler.indentListMore(); + onTriggered: { + root.focusedDocumentHandler.indentListMore(); + root.clicked(); + } } QQC2.MenuItem { icon.name: "format-indent-less" text: i18nc("@action:button", "Decrease List Level") - onTriggered: root.documentHandler.indentListLess(); + onTriggered: { + root.focusedDocumentHandler.indentListLess(); + root.clicked(); + } } } @@ -293,31 +357,56 @@ QQC2.ToolBar { QQC2.MenuItem { text: i18nc("@item:inmenu no heading", "Paragraph") - onTriggered: root.documentHandler.setHeadingLevel(0); + onTriggered: root.contentModel.insertComponentAtCursor(LibNeoChat.MessageComponentType.Text); } QQC2.MenuItem { text: i18nc("@item:inmenu heading level 1 (largest)", "Heading 1") - onTriggered: root.documentHandler.setHeadingLevel(1); + onTriggered: { + root.focusedDocumentHandler.style = LibNeoChat.ChatDocumentHandler.Heading1; + root.clicked(); + } } QQC2.MenuItem { text: i18nc("@item:inmenu heading level 2", "Heading 2") - onTriggered: root.documentHandler.setHeadingLevel(2); + onTriggered: { + root.focusedDocumentHandler.style = LibNeoChat.ChatDocumentHandler.Heading2; + root.clicked(); + } } QQC2.MenuItem { text: i18nc("@item:inmenu heading level 3", "Heading 3") - onTriggered: root.documentHandler.setHeadingLevel(3); + onTriggered: { + root.focusedDocumentHandler.style = LibNeoChat.ChatDocumentHandler.Heading3; + root.clicked(); + } } QQC2.MenuItem { text: i18nc("@item:inmenu heading level 4", "Heading 4") - onTriggered: root.documentHandler.setHeadingLevel(4); + onTriggered: { + root.focusedDocumentHandler.style = LibNeoChat.ChatDocumentHandler.Heading4; + root.clicked(); + } } QQC2.MenuItem { text: i18nc("@item:inmenu heading level 5", "Heading 5") - onTriggered: root.documentHandler.setHeadingLevel(5); + onTriggered: { + root.focusedDocumentHandler.style = LibNeoChat.ChatDocumentHandler.Heading5; + root.clicked(); + } } QQC2.MenuItem { text: i18nc("@item:inmenu heading level 6 (smallest)", "Heading 6") - onTriggered: root.documentHandler.setHeadingLevel(6); + onTriggered: { + root.focusedDocumentHandler.style = LibNeoChat.ChatDocumentHandler.Heading6; + root.clicked(); + } + } + QQC2.MenuItem { + text: i18nc("@item:inmenu", "Quote") + onTriggered: { + root.contentModel.insertComponentAtCursor(LibNeoChat.MessageComponentType.Quote); + root.clicked(); + } } } @@ -325,6 +414,20 @@ QQC2.ToolBar { QQC2.ToolTip.visible: hovered QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay } + QQC2.ToolButton { + id: codeButton + icon.name: "format-text-code" + text: i18n("Code") + display: QQC2.AbstractButton.IconOnly + + onClicked: { + root.contentModel.insertComponentAtCursor(LibNeoChat.MessageComponentType.Code); + root.clicked(); + } + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.text: text + } Kirigami.Separator { Layout.fillHeight: true Layout.margins: 0 @@ -354,10 +457,13 @@ QQC2.ToolBar { display: QQC2.AbstractButton.IconOnly onClicked: { let dialog = linkDialog.createObject(QQC2.Overlay.overlay, { - linkText: root.documentHandler.currentLinkText(), - linkUrl: root.documentHandler.currentLinkUrl() + linkText: root.focusedDocumentHandler.currentLinkText(), + linkUrl: root.focusedDocumentHandler.currentLinkUrl() }) - dialog.onAccepted.connect(() => { documentHandler.updateLink(dialog.linkUrl, dialog.linkText) }); + dialog.onAccepted.connect(() => { + root.focusedDocumentHandler.updateLink(dialog.linkUrl, dialog.linkText) + root.clicked(); + }); dialog.open(); } @@ -384,7 +490,7 @@ QQC2.ToolBar { onClicked: { let dialog = (LibNeoChat.Clipboard.hasImage ? attachDialog : openFileDialog).createObject(QQC2.Overlay.overlay); - dialog.chosen.connect(path => root.chatBarCache.attachmentPath = path); + dialog.chosen.connect(path => root.contentModel.addAttachment(path)); dialog.open(); } QQC2.ToolTip.visible: hovered @@ -482,9 +588,8 @@ QQC2.ToolBar { icon.name: "document-send" text: i18n("Send message") display: QQC2.AbstractButton.IconOnly - checkable: true - onClicked: root.requestPostMessage() + onClicked: root.contentModel.postMessage(); QQC2.ToolTip.visible: hovered QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay QQC2.ToolTip.text: text @@ -543,7 +648,7 @@ QQC2.ToolBar { currentRoom: root.room onChosen: emoji => { - root.documentHandler.insertText(emoji); + root.focusedDocumentHandler.insertText(emoji); close(); } onClosed: if (emojiButton.checked) { diff --git a/src/libneochat/CMakeLists.txt b/src/libneochat/CMakeLists.txt index 26edb2a31..8258f8f0b 100644 --- a/src/libneochat/CMakeLists.txt +++ b/src/libneochat/CMakeLists.txt @@ -27,7 +27,7 @@ target_sources(LibNeoChat PRIVATE utils.cpp voicerecorder.cpp enums/chatbartype.h - enums/messagecomponenttype.h + enums/messagecomponenttype.cpp enums/messagetype.h enums/powerlevel.cpp enums/pushrule.h diff --git a/src/libneochat/chatdocumenthandler.cpp b/src/libneochat/chatdocumenthandler.cpp index c1a79b71b..882ed6f9b 100644 --- a/src/libneochat/chatdocumenthandler.cpp +++ b/src/libneochat/chatdocumenthandler.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -20,17 +21,21 @@ #include #include +#include +#include +#include +#include #include "chatbartype.h" #include "chatdocumenthandler_logging.h" #include "eventhandler.h" -#include "utils.h" using namespace Qt::StringLiterals; class SyntaxHighlighter : public QSyntaxHighlighter { public: + QPointer room; QTextCharFormat mentionFormat; QTextCharFormat errorFormat; Sonnet::BackgroundChecker checker; @@ -82,11 +87,10 @@ public: if (!room) { return; } - const auto chatchache = handler->chatBarCache(); - if (!chatchache) { + if (!room) { return; } - auto mentions = chatchache->mentions(); + auto mentions = room->cacheForType(handler->type())->mentions(); mentions->erase(std::remove_if(mentions->begin(), mentions->end(), [this](auto &mention) { @@ -127,18 +131,8 @@ ChatDocumentHandler::ChatDocumentHandler(QObject *parent) { } -void ChatDocumentHandler::updateCompletion() const -{ - int start = completionStartIndex(); - m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start)); -} - int ChatDocumentHandler::completionStartIndex() const { - if (!m_room) { - return 0; - } - const qsizetype cursor = cursorPosition(); const auto &text = getText(); @@ -189,21 +183,108 @@ void ChatDocumentHandler::setTextItem(QQuickItem *textItem) m_highlighter->setDocument(document()); if (m_textItem) { - connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(updateCompletion())); + connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(updateCursor())); if (document()) { + connect(document(), &QTextDocument::contentsChanged, this, &ChatDocumentHandler::contentsChanged); connect(document(), &QTextDocument::contentsChanged, this, [this]() { if (m_room) { - m_room->cacheForType(m_type)->setText(getText()); - int start = completionStartIndex(); - m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start)); + updateCursor(); } }); + initializeChars(); } } Q_EMIT textItemChanged(); } +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 = 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 *ChatDocumentHandler::document() const { if (!m_textItem) { @@ -221,6 +302,16 @@ int ChatDocumentHandler::cursorPosition() const return m_textItem->property("cursorPosition").toInt(); } +void ChatDocumentHandler::updateCursor() +{ + int start = completionStartIndex(); + m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start)); + + Q_EMIT formatChanged(); + Q_EMIT atFirstLineChanged(); + Q_EMIT atLastLineChanged(); +} + int ChatDocumentHandler::selectionStart() const { if (!m_textItem) { @@ -248,46 +339,191 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room) return; } - if (m_room && m_type != ChatBarType::None) { - m_room->cacheForType(m_type)->disconnect(this); - if (!m_room->isSpace() && document() && m_type == ChatBarType::Room) { - m_room->mainCache()->setSavedText(document()->toPlainText()); - } - } - m_room = room; - m_completionModel->setRoom(m_room); - if (m_room && m_type != ChatBarType::None) { - connect(m_room->cacheForType(m_type), &ChatBarCache::textChanged, this, [this]() { - int start = completionStartIndex(); - m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start)); - Q_EMIT fontFamilyChanged(); - Q_EMIT textColorChanged(); - Q_EMIT alignmentChanged(); - Q_EMIT boldChanged(); - Q_EMIT italicChanged(); - Q_EMIT underlineChanged(); - Q_EMIT checkableChanged(); - Q_EMIT strikethroughChanged(); - Q_EMIT fontSizeChanged(); - Q_EMIT fileUrlChanged(); - }); - if (!m_room->isSpace() && document() && m_type == ChatBarType::Room) { - document()->setPlainText(room->mainCache()->savedText()); - m_room->mainCache()->setText(room->mainCache()->savedText()); - } - } - Q_EMIT roomChanged(); } -ChatBarCache *ChatDocumentHandler::chatBarCache() const +bool ChatDocumentHandler::isEmpty() const { - if (!m_room || m_type == ChatBarType::None) { - return nullptr; + return htmlText().length() == 0; +} + +bool ChatDocumentHandler::atFirstLine() const +{ + const auto cursor = 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 = textCursor(); + const auto doc = 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 = document(); + const auto item = textItem(); + if (!doc || !item) { + return; + } + + item->forceActiveFocus(); + + if (!previousDocumentHandler) { + const auto docLastBlockLayout = doc->lastBlock().layout(); + item->setProperty("cursorPosition", infront ? defaultPosition : docLastBlockLayout->lineAt(docLastBlockLayout->lineCount() - 1).textStart()); + item->setProperty("cursorVisible", true); + return; + } + + const auto previousLinePosition = previousDocumentHandler->cursorPositionInLine(); + const auto newMaxLineLength = lineLength(infront ? 0 : lineCount() - 1); + item->setProperty("cursorPosition", + std::min(previousLinePosition, newMaxLineLength ? *newMaxLineLength : defaultPosition) + (infront ? 0 : doc->lastBlock().position())); + item->setProperty("cursorVisible", true); +} + +int ChatDocumentHandler::lineCount() const +{ + if (const auto doc = document()) { + return doc->lineCount(); + } + return 0; +} + +std::optional ChatDocumentHandler::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(); +} + +int ChatDocumentHandler::cursorPositionInLine() const +{ + const auto cursor = textCursor(); + if (cursor.isNull()) { + return false; + } + return cursor.positionInBlock(); +} + +QTextDocumentFragment ChatDocumentHandler::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 removeMe(this); + } + return block; +} + +void ChatDocumentHandler::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 ChatDocumentHandler::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; + } + 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); + } + if (textItem()) { + textItem()->setProperty("cursorPosition", cursor.position()); } - return m_room->cacheForType(m_type); } void ChatDocumentHandler::complete(int index) @@ -436,7 +672,7 @@ void ChatDocumentHandler::setStrikethrough(bool strikethrough) QTextCharFormat format; format.setFontStrikeOut(strikethrough); mergeFormatOnWordOrSelection(format); - Q_EMIT underlineChanged(); + Q_EMIT formatChanged(); } void ChatDocumentHandler::setTextColor(const QColor &color) @@ -478,7 +714,7 @@ void ChatDocumentHandler::setBold(bool bold) QTextCharFormat format; format.setFontWeight(bold ? QFont::Bold : QFont::Normal); mergeFormatOnWordOrSelection(format); - Q_EMIT boldChanged(); + Q_EMIT formatChanged(); } bool ChatDocumentHandler::italic() const @@ -494,7 +730,7 @@ void ChatDocumentHandler::setItalic(bool italic) QTextCharFormat format; format.setFontItalic(italic); mergeFormatOnWordOrSelection(format); - Q_EMIT italicChanged(); + Q_EMIT formatChanged(); } bool ChatDocumentHandler::underline() const @@ -510,7 +746,7 @@ void ChatDocumentHandler::setUnderline(bool underline) QTextCharFormat format; format.setFontUnderline(underline); mergeFormatOnWordOrSelection(format); - Q_EMIT underlineChanged(); + Q_EMIT formatChanged(); } bool ChatDocumentHandler::strikethrough() const @@ -549,11 +785,11 @@ QColor ChatDocumentHandler::textColor() const QTextCursor ChatDocumentHandler::textCursor() const { - QTextDocument *doc = document(); - if (!doc) + if (!document()) { return QTextCursor(); + } - QTextCursor cursor = QTextCursor(doc); + QTextCursor cursor = QTextCursor(document()); if (selectionStart() != selectionEnd()) { cursor.setPosition(selectionStart()); cursor.setPosition(selectionEnd(), QTextCursor::KeepAnchor); @@ -563,6 +799,15 @@ QTextCursor ChatDocumentHandler::textCursor() const return cursor; } +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 = textCursor(); @@ -747,11 +992,6 @@ void ChatDocumentHandler::regenerateColorScheme() // TODO update existing link } -int ChatDocumentHandler::currentHeadingLevel() const -{ - return textCursor().blockFormat().headingLevel(); -} - void ChatDocumentHandler::indentListMore() { m_nestedListHelper.handleOnIndentMore(textCursor()); @@ -768,22 +1008,46 @@ void ChatDocumentHandler::setListStyle(int styleIndex) Q_EMIT currentListStyleChanged(); } -void ChatDocumentHandler::setHeadingLevel(int level) +bool ChatDocumentHandler::canIndentList() const { - const int boundedLevel = qBound(0, 6, level); + return m_nestedListHelper.canIndent(textCursor()) && textCursor().blockFormat().headingLevel() == 0; +} + +bool ChatDocumentHandler::canDedentList() const +{ + return m_nestedListHelper.canDedent(textCursor()) && textCursor().blockFormat().headingLevel() == 0; +} + +int ChatDocumentHandler::currentListStyle() const +{ + if (!textCursor().currentList()) { + return 0; + } + + return -textCursor().currentList()->format().style(); +} + +ChatDocumentHandler::Style ChatDocumentHandler::style() const +{ + return static_cast