diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 17495419f..5ec13ae1d 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -70,7 +70,6 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE qml/AttachmentPane.qml qml/QuickFormatBar.qml qml/UserDetailDialog.qml - qml/OpenFileDialog.qml qml/KeyVerificationDialog.qml qml/ConfirmLogoutDialog.qml qml/VerificationMessage.qml @@ -79,7 +78,6 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE qml/EmojiSas.qml qml/VerificationCanceled.qml qml/MessageSourceSheet.qml - qml/LocationChooser.qml qml/InvitationView.qml qml/AvatarTabButton.qml qml/OsmLocationPlugin.qml @@ -105,7 +103,6 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE qml/HoverLinkIndicator.qml qml/AvatarNotification.qml qml/ReasonDialog.qml - qml/NewPollDialog.qml qml/UserMenu.qml qml/MeetingDialog.qml qml/SeenByDialog.qml diff --git a/src/chatbar/CMakeLists.txt b/src/chatbar/CMakeLists.txt index 13cc7a693..0595bb2b0 100644 --- a/src/chatbar/CMakeLists.txt +++ b/src/chatbar/CMakeLists.txt @@ -8,6 +8,7 @@ ecm_add_qml_module(Chatbar GENERATE_PLUGIN_SOURCE QML_FILES AttachDialog.qml ChatBar.qml + RichEditBar.qml CompletionMenu.qml EmojiDelegate.qml EmojiGrid.qml @@ -17,4 +18,9 @@ ecm_add_qml_module(Chatbar GENERATE_PLUGIN_SOURCE EmojiTonesPicker.qml ImageEditorPage.qml VoiceMessageDialog.qml + ImageDialog.qml + LinkDialog.qml + LocationChooser.qml + NewPollDialog.qml + TableDialog.qml ) diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index d1a3b05d1..37a73a785 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -70,117 +70,6 @@ QQC2.Control { } } - /** - * @brief The list of actions in the ChatBar. - * - * Each of these will be visualised in the ChatBar so new actions can be added - * by appending to this list. - */ - property list actions: [ - BusyAction { - id: attachmentAction - - isBusy: root.currentRoom && root.currentRoom.hasFileUploading - - // Matrix does not allow sending attachments in replies - visible: _private.chatBarCache.replyId.length === 0 && _private.chatBarCache.attachmentPath.length === 0 - icon.name: "mail-attachment" - text: i18nc("@action:button", "Attach an image or file") - displayHint: Kirigami.DisplayHint.IconOnly - - onTriggered: { - if (Clipboard.hasImage) { - let dialog = attachDialog.createObject(root.QQC2.Overlay.overlay) as AttachDialog; - dialog.chosen.connect(path => _private.chatBarCache.attachmentPath = path); - dialog.open(); - } else { - let dialog = openFileDialog.createObject(root.QQC2.Overlay.overlay) as OpenFileDialog; - dialog.chosen.connect(path => _private.chatBarCache.attachmentPath = path); - dialog.open(); - } - } - - tooltip: text - }, - BusyAction { - id: emojiAction - - isBusy: false - - visible: !Kirigami.Settings.isMobile - icon.name: "smiley" - text: i18nc("@action:button", "Emojis & Stickers") - displayHint: Kirigami.DisplayHint.IconOnly - checkable: true - - onTriggered: { - if (emojiDialog.visible) { - emojiDialog.close(); - } else { - emojiDialog.open(); - } - } - tooltip: text - }, - BusyAction { - id: mapButton - icon.name: "mark-location-symbolic" - isBusy: false - text: i18nc("@action:button", "Send a Location") - displayHint: QQC2.AbstractButton.IconOnly - - onTriggered: { - (locationChooser.createObject(QQC2.Overlay.overlay, { - room: root.currentRoom - }) as LocationChooser).open(); - } - tooltip: text - }, - BusyAction { - id: pollButton - icon.name: "amarok_playcount" - isBusy: false - text: i18nc("@action:button", "Create a Poll") - displayHint: QQC2.AbstractButton.IconOnly - - onTriggered: { - (newPollDialog.createObject(QQC2.Overlay.overlay, { - room: root.currentRoom - }) as NewPollDialog).open(); - } - tooltip: text - }, - BusyAction { - icon.name: "microphone" - isBusy: false - text: i18nc("@action:button", "Send a Voice Message") - displayHint: QQC2.AbstractButton.IconOnly - onTriggered: { - let dialog = voiceMessageDialog.createObject(root, { - room: root.currentRoom - }) as VoiceMessageDialog; - dialog.open(); - } - tooltip: text - }, - BusyAction { - id: sendAction - - isBusy: false - - icon.name: "document-send" - text: i18nc("@action:button", "Send message") - displayHint: Kirigami.DisplayHint.IconOnly - checkable: true - - onTriggered: { - _private.postMessage(); - } - - tooltip: text - } - ] - spacing: 0 Kirigami.Theme.colorSet: Kirigami.Theme.View @@ -274,9 +163,7 @@ QQC2.Control { 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 - // This has to stay PlainText or else formatting starts breaking in strange ways - textFormat: TextEdit.PlainText - font.pointSize: Kirigami.Theme.defaultFont.pointSize * NeoChatConfig.fontScale + persistentSelection: true Accessible.description: placeholderText @@ -411,6 +298,18 @@ QQC2.Control { } } } + 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 @@ -468,11 +367,13 @@ QQC2.Control { QtObject { id: _private property ChatBarCache chatBarCache + onChatBarCacheChanged: { + richEditBar.chatBarCache = chatBarCache + } function postMessage() { _private.chatBarCache.postMessage(); repeatTimer.stop(); - root.currentRoom.markAllMessagesAsRead(); textField.clear(); } @@ -534,38 +435,6 @@ QQC2.Control { room: root.currentRoom } - Component { - id: openFileDialog - - OpenFileDialog { - parentWindow: Window.window - currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] - } - } - - Component { - id: attachDialog - - AttachDialog { - anchors.centerIn: parent - } - } - - Component { - id: locationChooser - LocationChooser {} - } - - Component { - id: newPollDialog - NewPollDialog {} - } - - Component { - id: voiceMessageDialog - VoiceMessageDialog {} - } - CompletionMenu { id: completionMenu chatDocumentHandler: documentHandler @@ -582,32 +451,4 @@ QQC2.Control { } } } - - EmojiDialog { - id: emojiDialog - - x: root.width - width - y: -implicitHeight - - modal: false - includeCustom: true - closeOnChosen: false - - currentRoom: root.currentRoom - - onChosen: emoji => root.insertText(emoji) - onClosed: if (emojiAction.checked) { - emojiAction.checked = false; - } - } - - function insertText(text) { - let initialCursorPosition = textField.cursorPosition; - textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition); - textField.cursorPosition = initialCursorPosition + text.length; - } - - component BusyAction : Kirigami.Action { - required property bool isBusy - } } diff --git a/src/chatbar/ImageDialog.qml b/src/chatbar/ImageDialog.qml new file mode 100644 index 000000000..dea6d5a13 --- /dev/null +++ b/src/chatbar/ImageDialog.qml @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2024 Carl Schwan +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtCore +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import QtQuick.Dialogs + +FormCard.FormCardDialog { + id: root + + readonly property alias imagePath: imageField.path + + title: i18nc("@title:window", "Insert Image") + standardButtons: QQC2.Dialog.Ok | QQC2.Dialog.Cancel + + FileDialog { + id: fileDialog + + title: i18nc("@title:window", "Select an image") + currentFolder: StandardPaths.writableLocation(StandardPaths.PicturesLocation) + fileMode: FileDialog.OpenFile + nameFilters: [i18n("Image files (*.jpg *.jpeg *.png *.svg *.webp)"), i18n("All files (*)")] + onAccepted: imageField.path = selectedFile + } + + FormCard.FormButtonDelegate { + id: imageField + + property url path + + text: i18nc("@label:textbox", "Image Location:") + description: path.toString().length > 0 ? path.toString().split('/').slice(-1)[0] : '' + + onClicked: fileDialog.open() + } + + Item { + visible: imageField.path.toString().length > 0 + + Layout.fillWidth: true + Layout.preferredHeight: 200 + Layout.topMargin: Kirigami.Units.largeSpacing + + Image { + anchors.fill: parent + source: imageField.path + fillMode: Image.PreserveAspectFit + horizontalAlignment: Image.AlignHCenter + } + } +} diff --git a/src/chatbar/LinkDialog.qml b/src/chatbar/LinkDialog.qml new file mode 100644 index 000000000..0fceb64d4 --- /dev/null +++ b/src/chatbar/LinkDialog.qml @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2024 Carl Schwan +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard + +FormCard.FormCardDialog { + id: root + + property alias linkText: linkTextField.text + property alias linkUrl: linkUrlField.text + + title: i18nc("@title:window", "Insert Link") + standardButtons: QQC2.Dialog.Ok | QQC2.Dialog.Cancel + + FormCard.FormTextFieldDelegate { + id: linkTextField + + label: i18nc("@label:textbox", "Link Text:") + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + id: linkUrlField + + label: i18nc("@label:textbox", "Link URL:") + } +} diff --git a/src/app/qml/LocationChooser.qml b/src/chatbar/LocationChooser.qml similarity index 100% rename from src/app/qml/LocationChooser.qml rename to src/chatbar/LocationChooser.qml diff --git a/src/app/qml/NewPollDialog.qml b/src/chatbar/NewPollDialog.qml similarity index 100% rename from src/app/qml/NewPollDialog.qml rename to src/chatbar/NewPollDialog.qml diff --git a/src/chatbar/RichEditBar.qml b/src/chatbar/RichEditBar.qml new file mode 100644 index 000000000..5291c4c2c --- /dev/null +++ b/src/chatbar/RichEditBar.qml @@ -0,0 +1,564 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +import org.kde.neochat.libneochat as LibNeoChat + +QQC2.ToolBar { + id: root + + /** + * @brief The current room that user is viewing. + */ + required property LibNeoChat.NeoChatRoom room + + property LibNeoChat.ChatBarCache chatBarCache + + required property LibNeoChat.ChatDocumentHandler documentHandler + + required property real maxAvailableWidth + + readonly property real uncompressedImplicitWidth: textFormatRow.implicitWidth + + listRow.implicitWidth + + styleButton.implicitWidth + + emojiButton.implicitWidth + + linkButton.implicitWidth + + sendRow.implicitWidth + + sendButton.implicitWidth + + buttonRow.spacing * 9 + + 3 + + readonly property real listCompressedImplicitWidth: textFormatRow.implicitWidth + + compressedListButton.implicitWidth + + styleButton.implicitWidth + + emojiButton.implicitWidth + + linkButton.implicitWidth + + sendRow.implicitWidth + + sendButton.implicitWidth + + buttonRow.spacing * 9 + + 3 + + readonly property real textFormatCompressedImplicitWidth: compressedTextFormatButton.implicitWidth + + compressedListButton.implicitWidth + + styleButton.implicitWidth + + emojiButton.implicitWidth + + linkButton.implicitWidth + + sendRow.implicitWidth + + sendButton.implicitWidth + + buttonRow.spacing * 9 + + 3 + + signal requestPostMessage + + RowLayout { + id: buttonRow + RowLayout { + id: textFormatRow + visible: root.maxAvailableWidth > root.listCompressedImplicitWidth + QQC2.ToolButton { + id: boldButton + Shortcut { + sequence: "Ctrl+B" + onActivated: boldButton.clicked() + } + icon.name: "format-text-bold" + text: i18nc("@action:button", "Bold") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: root.documentHandler.bold + onClicked: root.documentHandler.bold = checked; + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + id: italicButton + Shortcut { + sequence: "Ctrl+I" + onActivated: italicButton.clicked() + } + icon.name: "format-text-italic" + text: i18nc("@action:button", "Italic") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: root.documentHandler.italic + onClicked: root.documentHandler.italic = checked; + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + id: underlineButton + Shortcut { + sequence: "Ctrl+U" + onActivated: underlineButton.clicked() + } + icon.name: "format-text-underline" + text: i18nc("@action:button", "Underline") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: root.documentHandler.underline + onClicked: root.documentHandler.underline = checked; + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + icon.name: "format-text-strikethrough" + text: i18nc("@action:button", "Strikethrough") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: root.documentHandler.strikethrough + onClicked: root.documentHandler.strikethrough = checked; + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + QQC2.ToolButton { + id: compressedTextFormatButton + visible: root.maxAvailableWidth < root.listCompressedImplicitWidth + icon.name: "dialog-text-and-font" + text: i18nc("@action:button", "Format Text") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: compressedTextFormatMenu.visible + onClicked: { + compressedTextFormatMenu.open() + } + + QQC2.Menu { + id: compressedTextFormatMenu + y: -implicitHeight + + QQC2.MenuItem { + icon.name: "format-text-bold" + text: i18nc("@action:button", "Bold") + checkable: true + checked: root.documentHandler.bold + onTriggered: root.documentHandler.bold = checked; + } + QQC2.MenuItem { + icon.name: "format-text-italic" + text: i18nc("@action:button", "Italic") + checkable: true + checked: root.documentHandler.italic + onTriggered: root.documentHandler.italic = checked; + } + QQC2.MenuItem { + icon.name: "format-text-underline" + text: i18nc("@action:button", "Underline") + checkable: true + checked: root.documentHandler.underline + onTriggered: root.documentHandler.underline = checked; + } + QQC2.MenuItem { + icon.name: "format-text-strikethrough" + text: i18nc("@action:button", "Strikethrough") + checkable: true + checked: root.documentHandler.strikethrough + onTriggered: root.documentHandler.strikethrough = checked; + } + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + Kirigami.Separator { + Layout.fillHeight: true + Layout.margins: 0 + } + RowLayout { + id: listRow + visible: root.maxAvailableWidth > root.uncompressedImplicitWidth + QQC2.ToolButton { + icon.name: "format-list-unordered" + text: i18nc("@action:button", "Unordered List") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: root.documentHandler.currentListStyle === 1 + onClicked: { + root.documentHandler.setListStyle(root.documentHandler.currentListStyle === 1 ? 0 : 1) + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + icon.name: "format-list-ordered" + 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) + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + id: indentAction + icon.name: "format-indent-more" + text: i18nc("@action:button", "Increase List Level") + display: QQC2.AbstractButton.IconOnly + onClicked: { + root.documentHandler.indentListMore(); + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + id: dedentAction + icon.name: "format-indent-less" + text: i18nc("@action:button", "Decrease List Level") + display: QQC2.AbstractButton.IconOnly + onClicked: { + root.documentHandler.indentListLess(); + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + QQC2.ToolButton { + id: compressedListButton + visible: root.maxAvailableWidth < root.uncompressedImplicitWidth + icon.name: "format-list-unordered" + text: i18nc("@action:button", "List Style") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: compressedListMenu.visible + onClicked: { + compressedListMenu.open() + } + + QQC2.Menu { + id: compressedListMenu + y: -implicitHeight + + QQC2.MenuItem { + icon.name: "format-list-unordered" + text: i18nc("@action:button", "Unordered List") + onTriggered: root.documentHandler.setListStyle(root.documentHandler.currentListStyle === 1 ? 0 : 1); + } + QQC2.MenuItem { + icon.name: "format-list-ordered" + text: i18nc("@action:button", "Ordered List") + onTriggered: root.documentHandler.setListStyle(root.documentHandler.currentListStyle === 4 ? 0 : 4); + } + QQC2.MenuItem { + icon.name: "format-indent-more" + text: i18nc("@action:button", "Increase List Level") + onTriggered: root.documentHandler.indentListMore(); + } + QQC2.MenuItem { + icon.name: "format-indent-less" + text: i18nc("@action:button", "Decrease List Level") + onTriggered: root.documentHandler.indentListLess(); + } + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + id: styleButton + icon.name: "typewriter" + text: i18nc("@action:button", "Text Style") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: styleMenu.visible + onClicked: { + styleMenu.open() + } + + QQC2.Menu { + id: styleMenu + y: -implicitHeight + + QQC2.MenuItem { + text: i18nc("@item:inmenu no heading", "Paragraph") + onTriggered: root.documentHandler.setHeadingLevel(0); + } + QQC2.MenuItem { + text: i18nc("@item:inmenu heading level 1 (largest)", "Heading 1") + onTriggered: root.documentHandler.setHeadingLevel(1); + } + QQC2.MenuItem { + text: i18nc("@item:inmenu heading level 2", "Heading 2") + onTriggered: root.documentHandler.setHeadingLevel(2); + } + QQC2.MenuItem { + text: i18nc("@item:inmenu heading level 3", "Heading 3") + onTriggered: root.documentHandler.setHeadingLevel(3); + } + QQC2.MenuItem { + text: i18nc("@item:inmenu heading level 4", "Heading 4") + onTriggered: root.documentHandler.setHeadingLevel(4); + } + QQC2.MenuItem { + text: i18nc("@item:inmenu heading level 5", "Heading 5") + onTriggered: root.documentHandler.setHeadingLevel(5); + } + QQC2.MenuItem { + text: i18nc("@item:inmenu heading level 6 (smallest)", "Heading 6") + onTriggered: root.documentHandler.setHeadingLevel(6); + } + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + Kirigami.Separator { + Layout.fillHeight: true + Layout.margins: 0 + } + QQC2.ToolButton { + id: emojiButton + + property bool isBusy: false + + visible: !Kirigami.Settings.isMobile + icon.name: "smiley" + text: i18n("Emojis & Stickers") + display: QQC2.AbstractButton.IconOnly + checkable: true + + onClicked: { + let dialog = emojiDialog.createObject(root).open(); + } + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.text: text + } + QQC2.ToolButton { + id: linkButton + icon.name: "insert-link-symbolic" + text: i18nc("@action:button", "Insert link") + display: QQC2.AbstractButton.IconOnly + onClicked: { + let dialog = linkDialog.createObject(QQC2.Overlay.overlay, { + linkText: root.documentHandler.currentLinkText(), + linkUrl: root.documentHandler.currentLinkUrl() + }) + dialog.onAccepted.connect(() => { documentHandler.updateLink(dialog.linkUrl, dialog.linkText) }); + dialog.open(); + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + Kirigami.Separator { + Layout.fillHeight: true + Layout.margins: 0 + } + RowLayout { + id: sendRow + visible: root.maxAvailableWidth > root.textFormatCompressedImplicitWidth + QQC2.ToolButton { + id: attachmentButton + + property bool isBusy: root.room && root.room.hasFileUploading + + visible: root.chatBarCache.attachmentPath.length === 0 + icon.name: "mail-attachment" + text: i18n("Attach an image or file") + display: QQC2.AbstractButton.IconOnly + + onClicked: { + let dialog = (LibNeoChat.Clipboard.hasImage ? attachDialog : openFileDialog).createObject(QQC2.Overlay.overlay); + dialog.chosen.connect(path => root.chatBarCache.attachmentPath = path); + dialog.open(); + } + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.text: text + } + QQC2.ToolButton { + id: mapButton + icon.name: "globe" + property bool isBusy: false + text: i18n("Send a Location") + display: QQC2.AbstractButton.IconOnly + + onClicked: { + locationChooser.createObject(QQC2.ApplicationWindow.overlay, { + room: root.room + }).open(); + } + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.text: text + } + QQC2.ToolButton { + id: pollButton + icon.name: "amarok_playcount" + property bool isBusy: false + text: i18nc("@action:button", "Create a Poll") + display: QQC2.AbstractButton.IconOnly + + onClicked: { + newPollDialog.createObject(QQC2.Overlay.overlay, { + room: root.room + }).open(); + } + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.text: text + } + } + QQC2.ToolButton { + id: compressedSendButton + visible: root.maxAvailableWidth < root.textFormatCompressedImplicitWidth + icon.name: "overflow-menu" + text: i18nc("@action:button", "Send Other") + display: QQC2.AbstractButton.IconOnly + checkable: true + checked: compressedSendMenu.visible + onClicked: { + compressedSendMenu.open() + } + + QQC2.Menu { + id: compressedSendMenu + y: -implicitHeight + + QQC2.MenuItem { + visible: root.chatBarCache.attachmentPath.length === 0 + icon.name: "mail-attachment" + text: i18n("Attach an image or file") + onTriggered: { + let dialog = (LibNeoChat.Clipboard.hasImage ? attachDialog : openFileDialog).createObject(QQC2.Overlay.overlay); + dialog.chosen.connect(path => root.chatBarCache.attachmentPath = path); + dialog.open(); + } + } + QQC2.MenuItem { + icon.name: "globe" + text: i18n("Send a Location") + onTriggered: { + locationChooser.createObject(QQC2.ApplicationWindow.overlay, { + room: root.room + }).open(); + } + } + QQC2.MenuItem { + icon.name: "amarok_playcount" + text: i18nc("@action:button", "Create a Poll") + onTriggered: { + newPollDialog.createObject(QQC2.Overlay.overlay, { + room: root.room + }).open(); + } + } + } + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + QQC2.ToolButton { + id: sendButton + + property bool isBusy: false + + icon.name: "document-send" + text: i18n("Send message") + display: QQC2.AbstractButton.IconOnly + checkable: true + + onClicked: root.requestPostMessage() + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.text: text + } + } + + background: Kirigami.ShadowedRectangle { + color: Kirigami.Theme.backgroundColor + radius: 5 + + shadow { + size: 15 + yOffset: 3 + color: Qt.rgba(0, 0, 0, 0.2) + } + + border { + color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.textColor, 0.2) + width: 1 + } + + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Window + } + + Component { + id: linkDialog + LinkDialog {} + } + + Component { + id: attachDialog + AttachDialog { + anchors.centerIn: parent + } + } + + Component { + id: openFileDialog + LibNeoChat.OpenFileDialog { + parentWindow: Window.window + currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] + } + } + + Component { + id: emojiDialog + EmojiDialog { + x: root.width - width + y: -implicitHeight + + modal: false + includeCustom: true + closeOnChosen: false + + currentRoom: root.room + + onChosen: emoji => { + root.documentHandler.insertText(emoji); + close(); + } + onClosed: if (emojiButton.checked) { + emojiButton.checked = false; + } + } + } + + Component { + id: locationChooser + LocationChooser {} + } + + Component { + id: newPollDialog + NewPollDialog {} + } +} diff --git a/src/chatbar/TableDialog.qml b/src/chatbar/TableDialog.qml new file mode 100644 index 000000000..e55c6ef26 --- /dev/null +++ b/src/chatbar/TableDialog.qml @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2024 Carl Schwan +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtCore +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import QtQuick.Dialogs + +FormCard.FormCardDialog { + id: root + + readonly property alias rows: rowsSpinBox.value + readonly property alias cols: colsSpinBox.value + + title: i18nc("@title:window", "Insert Table") + standardButtons: QQC2.Dialog.Ok | QQC2.Dialog.Cancel + + FormCard.FormSpinBoxDelegate { + id: rowsSpinBox + label: i18nc("@label:textbox", "Number of Rows:") + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormSpinBoxDelegate { + id: colsSpinBox + label: i18nc("@label:textbox", "Number of Columns:") + } +} diff --git a/src/libneochat/CMakeLists.txt b/src/libneochat/CMakeLists.txt index 18a76b1bd..26edb2a31 100644 --- a/src/libneochat/CMakeLists.txt +++ b/src/libneochat/CMakeLists.txt @@ -18,6 +18,8 @@ target_sources(LibNeoChat PRIVATE filetype.cpp linkpreviewer.cpp neochatdatetime.cpp + nestedlisthelper_p.h + nestedlisthelper.cpp roomlastmessageprovider.cpp spacehierarchycache.cpp texthandler.cpp @@ -68,6 +70,7 @@ ecm_add_qml_module(LibNeoChat GENERATE_PLUGIN_SOURCE qml/SearchPage.qml qml/CreateRoomDialog.qml qml/CreateSpaceDialog.qml + qml/OpenFileDialog.qml DEPENDENCIES io.github.quotient_im.libquotient ) @@ -100,6 +103,7 @@ target_link_libraries(LibNeoChat PUBLIC Qt::Multimedia Qt::Quick Qt::QuickControls2 + KF6::ColorScheme KF6::ConfigCore KF6::CoreAddons KF6::I18n diff --git a/src/libneochat/chatdocumenthandler.cpp b/src/libneochat/chatdocumenthandler.cpp index f898fdc40..c1a79b71b 100644 --- a/src/libneochat/chatdocumenthandler.cpp +++ b/src/libneochat/chatdocumenthandler.cpp @@ -11,15 +11,20 @@ #include #include #include +#include +#include #include #include +#include + #include #include #include "chatbartype.h" #include "chatdocumenthandler_logging.h" #include "eventhandler.h" +#include "utils.h" using namespace Qt::StringLiterals; @@ -216,6 +221,22 @@ int ChatDocumentHandler::cursorPosition() const return m_textItem->property("cursorPosition").toInt(); } +int ChatDocumentHandler::selectionStart() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("selectionStart").toInt(); +} + +int ChatDocumentHandler::selectionEnd() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("selectionEnd").toInt(); +} + NeoChatRoom *ChatDocumentHandler::room() const { return m_room; @@ -241,6 +262,16 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room) 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()); @@ -379,4 +410,478 @@ void ChatDocumentHandler::updateMentions(const QString &editId) } } +void ChatDocumentHandler::setFontSize(int size) +{ + if (size <= 0) + return; + + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return; + + if (!cursor.hasSelection()) + cursor.select(QTextCursor::WordUnderCursor); + + if (cursor.charFormat().property(QTextFormat::FontPointSize).toInt() == size) + return; + + QTextCharFormat format; + format.setFontPointSize(size); + mergeFormatOnWordOrSelection(format); + Q_EMIT fontSizeChanged(); +} + +void ChatDocumentHandler::setStrikethrough(bool strikethrough) +{ + QTextCharFormat format; + format.setFontStrikeOut(strikethrough); + mergeFormatOnWordOrSelection(format); + Q_EMIT underlineChanged(); +} + +void ChatDocumentHandler::setTextColor(const QColor &color) +{ + QTextCharFormat format; + format.setForeground(QBrush(color)); + mergeFormatOnWordOrSelection(format); + Q_EMIT textColorChanged(); +} + +Qt::Alignment ChatDocumentHandler::alignment() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return Qt::AlignLeft; + return textCursor().blockFormat().alignment(); +} + +void ChatDocumentHandler::setAlignment(Qt::Alignment alignment) +{ + QTextBlockFormat format; + format.setAlignment(alignment); + QTextCursor cursor = textCursor(); + cursor.mergeBlockFormat(format); + Q_EMIT alignmentChanged(); +} + +bool ChatDocumentHandler::bold() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) { + return false; + } + return textCursor().charFormat().fontWeight() == QFont::Bold; +} + +void ChatDocumentHandler::setBold(bool bold) +{ + QTextCharFormat format; + format.setFontWeight(bold ? QFont::Bold : QFont::Normal); + mergeFormatOnWordOrSelection(format); + Q_EMIT boldChanged(); +} + +bool ChatDocumentHandler::italic() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return false; + return textCursor().charFormat().fontItalic(); +} + +void ChatDocumentHandler::setItalic(bool italic) +{ + QTextCharFormat format; + format.setFontItalic(italic); + mergeFormatOnWordOrSelection(format); + Q_EMIT italicChanged(); +} + +bool ChatDocumentHandler::underline() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return false; + return textCursor().charFormat().fontUnderline(); +} + +void ChatDocumentHandler::setUnderline(bool underline) +{ + QTextCharFormat format; + format.setFontUnderline(underline); + mergeFormatOnWordOrSelection(format); + Q_EMIT underlineChanged(); +} + +bool ChatDocumentHandler::strikethrough() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return false; + return textCursor().charFormat().fontStrikeOut(); +} + +QString ChatDocumentHandler::fontFamily() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return QString(); + QTextCharFormat format = cursor.charFormat(); + return format.font().family(); +} + +void ChatDocumentHandler::setFontFamily(const QString &family) +{ + QTextCharFormat format; + format.setFontFamilies({family}); + mergeFormatOnWordOrSelection(format); + Q_EMIT fontFamilyChanged(); +} + +QColor ChatDocumentHandler::textColor() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return QColor(Qt::black); + QTextCharFormat format = cursor.charFormat(); + return format.foreground().color(); +} + +QTextCursor ChatDocumentHandler::textCursor() const +{ + QTextDocument *doc = document(); + if (!doc) + return QTextCursor(); + + QTextCursor cursor = QTextCursor(doc); + if (selectionStart() != selectionEnd()) { + cursor.setPosition(selectionStart()); + cursor.setPosition(selectionEnd(), QTextCursor::KeepAnchor); + } else { + cursor.setPosition(cursorPosition()); + } + return cursor; +} + +void ChatDocumentHandler::mergeFormatOnWordOrSelection(const QTextCharFormat &format) +{ + QTextCursor cursor = textCursor(); + if (!cursor.hasSelection()) + cursor.select(QTextCursor::WordUnderCursor); + cursor.mergeCharFormat(format); +} + +QString ChatDocumentHandler::currentLinkText() const +{ + QTextCursor cursor = textCursor(); + selectLinkText(&cursor); + return cursor.selectedText(); +} + +void ChatDocumentHandler::selectLinkText(QTextCursor *cursor) const +{ + // If the cursor is on a link, select the text of the link. + if (cursor->charFormat().isAnchor()) { + const QString aHref = cursor->charFormat().anchorHref(); + + // Move cursor to start of link + while (cursor->charFormat().anchorHref() == aHref) { + if (cursor->atStart()) { + break; + } + cursor->setPosition(cursor->position() - 1); + } + if (cursor->charFormat().anchorHref() != aHref) { + cursor->setPosition(cursor->position() + 1, QTextCursor::KeepAnchor); + } + + // Move selection to the end of the link + while (cursor->charFormat().anchorHref() == aHref) { + if (cursor->atEnd()) { + break; + } + const int oldPosition = cursor->position(); + cursor->movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); + // Wordaround Qt Bug. when we have a table. + // FIXME selection url + if (oldPosition == cursor->position()) { + break; + } + } + if (cursor->charFormat().anchorHref() != aHref) { + cursor->setPosition(cursor->position() - 1, QTextCursor::KeepAnchor); + } + } else if (cursor->hasSelection()) { + // Nothing to do. Using the currently selected text as the link text. + } else { + // Select current word + cursor->movePosition(QTextCursor::StartOfWord); + cursor->movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + } +} + +void ChatDocumentHandler::insertImage(const QUrl &url) +{ + if (!url.isLocalFile()) { + return; + } + + QImage image; + if (!image.load(url.path())) { + return; + } + + // Ensure we are putting the image in a new line and not in a list has it + // breaks the Qt rendering + textCursor().insertHtml(QStringLiteral("
")); + + while (canDedentList()) { + m_nestedListHelper.handleOnIndentLess(textCursor()); + } + + textCursor().insertHtml(QStringLiteral("")); +} + +void ChatDocumentHandler::insertTable(int rows, int columns) +{ + QString htmlText; + + QTextCursor cursor = textCursor(); + QTextTableFormat tableFormat; + tableFormat.setBorder(1); + const int numberOfColumns(columns); + QList constrains; + constrains.reserve(numberOfColumns); + const QTextLength::Type type = QTextLength::PercentageLength; + const int length = 100; // 100% of window width + + const QTextLength textlength(type, length / numberOfColumns); + for (int i = 0; i < numberOfColumns; ++i) { + constrains.append(textlength); + } + tableFormat.setColumnWidthConstraints(constrains); + tableFormat.setAlignment(Qt::AlignLeft); + tableFormat.setCellSpacing(0); + tableFormat.setCellPadding(4); + tableFormat.setBorderCollapse(true); + tableFormat.setBorder(0.5); + tableFormat.setTopMargin(20); + + Q_ASSERT(cursor.document()); + QTextTable *table = cursor.insertTable(rows, numberOfColumns, tableFormat); + + // fill table with whitespace + for (int i = 0, rows = table->rows(); i < rows; i++) { + for (int j = 0, columns = table->columns(); j < columns; j++) { + auto cell = table->cellAt(i, j); + Q_ASSERT(cell.isValid()); + cell.firstCursorPosition().insertText(QStringLiteral(" ")); + } + } + return; +} + +void ChatDocumentHandler::updateLink(const QString &linkUrl, const QString &linkText) +{ + auto cursor = textCursor(); + selectLinkText(&cursor); + + cursor.beginEditBlock(); + + if (!cursor.hasSelection()) { + cursor.select(QTextCursor::WordUnderCursor); + } + + QTextCharFormat format = cursor.charFormat(); + // Save original format to create an extra space with the existing char + // format for the block + if (!linkUrl.isEmpty()) { + // Add link details + format.setAnchor(true); + format.setAnchorHref(linkUrl); + // Workaround for QTBUG-1814: + // Link formatting does not get applied immediately when setAnchor(true) + // is called. So the formatting needs to be applied manually. + format.setUnderlineStyle(QTextCharFormat::SingleUnderline); + format.setUnderlineColor(linkColor()); + format.setForeground(linkColor()); + } else { + // Remove link details + format.setAnchor(false); + format.setAnchorHref(QString()); + // Workaround for QTBUG-1814: + // Link formatting does not get removed immediately when setAnchor(false) + // is called. So the formatting needs to be applied manually. + QTextDocument defaultTextDocument; + QTextCharFormat defaultCharFormat = defaultTextDocument.begin().charFormat(); + + format.setUnderlineStyle(defaultCharFormat.underlineStyle()); + format.setUnderlineColor(defaultCharFormat.underlineColor()); + format.setForeground(defaultCharFormat.foreground()); + } + + // Insert link text specified in dialog, otherwise write out url. + QString _linkText; + if (!linkText.isEmpty()) { + _linkText = linkText; + } else { + _linkText = linkUrl; + } + cursor.insertText(_linkText, format); + + cursor.endEditBlock(); +} + +QColor ChatDocumentHandler::linkColor() +{ + if (mLinkColor.isValid()) { + return mLinkColor; + } + regenerateColorScheme(); + return mLinkColor; +} + +void ChatDocumentHandler::regenerateColorScheme() +{ + mLinkColor = KColorScheme(QPalette::Active, KColorScheme::View).foreground(KColorScheme::LinkText).color(); + // TODO update existing link +} + +int ChatDocumentHandler::currentHeadingLevel() const +{ + return textCursor().blockFormat().headingLevel(); +} + +void ChatDocumentHandler::indentListMore() +{ + m_nestedListHelper.handleOnIndentMore(textCursor()); +} + +void ChatDocumentHandler::indentListLess() +{ + m_nestedListHelper.handleOnIndentLess(textCursor()); +} + +void ChatDocumentHandler::setListStyle(int styleIndex) +{ + m_nestedListHelper.handleOnBulletType(-styleIndex, textCursor()); + Q_EMIT currentListStyleChanged(); +} + +void ChatDocumentHandler::setHeadingLevel(int level) +{ + const int boundedLevel = qBound(0, 6, level); + // Apparently, 5 is maximum for FontSizeAdjustment; otherwise level=1 and + // level=2 look the same + const int sizeAdjustment = boundedLevel > 0 ? 5 - boundedLevel : 0; + + QTextCursor cursor = textCursor(); + cursor.beginEditBlock(); + + QTextBlockFormat blkfmt; + blkfmt.setHeadingLevel(boundedLevel); + cursor.mergeBlockFormat(blkfmt); + + QTextCharFormat chrfmt; + chrfmt.setFontWeight(boundedLevel > 0 ? QFont::Bold : QFont::Normal); + chrfmt.setProperty(QTextFormat::FontSizeAdjustment, sizeAdjustment); + // 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); + } + selectCursor.mergeCharFormat(chrfmt); + + cursor.mergeBlockCharFormat(chrfmt); + cursor.endEditBlock(); + // richTextComposer()->setTextCursor(cursor); + // richTextComposer()->setFocus(); + // richTextComposer()->activateRichText(); +} + +bool ChatDocumentHandler::canIndentList() const +{ + 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(); +} + +int ChatDocumentHandler::fontSize() const +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) + return 0; + QTextCharFormat format = cursor.charFormat(); + return format.font().pointSize(); +} + +QString ChatDocumentHandler::fileName() const +{ + const QString filePath = QQmlFile::urlToLocalFileOrQrc(m_fileUrl); + const QString fileName = QFileInfo(filePath).fileName(); + if (fileName.isEmpty()) + return QStringLiteral("untitled.txt"); + return fileName; +} + +QString ChatDocumentHandler::fileType() const +{ + return QFileInfo(fileName()).suffix(); +} + +QUrl ChatDocumentHandler::fileUrl() const +{ + return m_fileUrl; +} + +void ChatDocumentHandler::insertText(const QString &text) +{ + textCursor().insertText(text); +} + +QString ChatDocumentHandler::currentLinkUrl() const +{ + return textCursor().charFormat().anchorHref(); +} + +void ChatDocumentHandler::dumpHtml() +{ + qWarning() << htmlText(); +} + +QString ChatDocumentHandler::htmlText() +{ + auto text = document()->toMarkdown(); + while (text.startsWith(u"\n"_s)) { + text.remove(0, 1); + } + while (text.endsWith(u"\n"_s)) { + text.remove(text.size() - 1, text.size()); + } + return text; +} + #include "moc_chatdocumenthandler.cpp" diff --git a/src/libneochat/chatdocumenthandler.h b/src/libneochat/chatdocumenthandler.h index 3068d9d75..7c60aa778 100644 --- a/src/libneochat/chatdocumenthandler.h +++ b/src/libneochat/chatdocumenthandler.h @@ -12,6 +12,7 @@ #include "enums/chatbartype.h" #include "models/completionmodel.h" #include "neochatroom.h" +#include "nestedlisthelper_p.h" class QTextDocument; @@ -88,6 +89,28 @@ class ChatDocumentHandler : public QObject */ Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor NOTIFY textColorChanged) + Q_PROPERTY(QString fontFamily READ fontFamily WRITE setFontFamily NOTIFY fontFamilyChanged) + Q_PROPERTY(Qt::Alignment alignment READ alignment WRITE setAlignment NOTIFY alignmentChanged) + + Q_PROPERTY(bool bold READ bold WRITE setBold NOTIFY boldChanged) + Q_PROPERTY(bool italic READ italic WRITE setItalic NOTIFY italicChanged) + Q_PROPERTY(bool underline READ underline WRITE setUnderline NOTIFY underlineChanged) + Q_PROPERTY(bool strikethrough READ strikethrough WRITE setStrikethrough NOTIFY strikethroughChanged) + + Q_PROPERTY(bool canIndentList READ canIndentList NOTIFY cursorPositionChanged) + Q_PROPERTY(bool canDedentList READ canDedentList NOTIFY cursorPositionChanged) + Q_PROPERTY(int currentListStyle READ currentListStyle NOTIFY currentListStyleChanged) + Q_PROPERTY(int currentHeadingLevel READ currentHeadingLevel NOTIFY cursorPositionChanged) + + // Q_PROPERTY(bool list READ list WRITE setList NOTIFY listChanged) + + Q_PROPERTY(int fontSize READ fontSize WRITE setFontSize NOTIFY fontSizeChanged) + + Q_PROPERTY(QString fileName READ fileName NOTIFY fileUrlChanged) + Q_PROPERTY(QString fileType READ fileType NOTIFY fileUrlChanged) + Q_PROPERTY(QUrl fileUrl READ fileUrl NOTIFY fileUrlChanged) + public: explicit ChatDocumentHandler(QObject *parent = nullptr); @@ -111,13 +134,76 @@ public: */ Q_INVOKABLE void updateMentions(const QString &editId); + QString fontFamily() const; + void setFontFamily(const QString &family); + + QColor textColor() const; + void setTextColor(const QColor &color); + + Qt::Alignment alignment() const; + void setAlignment(Qt::Alignment alignment); + + bool bold() const; + void setBold(bool bold); + + bool italic() const; + void setItalic(bool italic); + + bool underline() const; + void setUnderline(bool underline); + + bool strikethrough() const; + void setStrikethrough(bool strikethrough); + + bool canIndentList() const; + bool canDedentList() const; + int currentListStyle() const; + + int currentHeadingLevel() const; + + // bool list() const; + // void setList(bool list); + + int fontSize() const; + void setFontSize(int size); + + QString fileName() const; + QString fileType() const; + QUrl fileUrl() const; + + Q_INVOKABLE void insertText(const QString &text); + Q_INVOKABLE QString currentLinkUrl() const; + Q_INVOKABLE QString currentLinkText() const; + Q_INVOKABLE void updateLink(const QString &linkUrl, const QString &linkText); + Q_INVOKABLE void insertImage(const QUrl &imagePath); + Q_INVOKABLE void insertTable(int rows, int columns); + + Q_INVOKABLE void indentListLess(); + Q_INVOKABLE void indentListMore(); + + Q_INVOKABLE void setListStyle(int styleIndex); + Q_INVOKABLE void setHeadingLevel(int level); + + Q_INVOKABLE void dumpHtml(); + Q_INVOKABLE QString htmlText(); + Q_SIGNALS: void typeChanged(); void textItemChanged(); void roomChanged(); -public Q_SLOTS: - void updateCompletion() const; + void fontFamilyChanged(); + void textColorChanged(); + void alignmentChanged(); + + void boldChanged(); + void italicChanged(); + void underlineChanged(); + void checkableChanged(); + void strikethroughChanged(); + void currentListStyleChanged(); + void fontSizeChanged(); + void fileUrlChanged(); private: ChatBarType::Type m_type = ChatBarType::None; @@ -129,11 +215,22 @@ private: QPointer m_room; int cursorPosition() const; + int selectionStart() const; + int selectionEnd() const; QString getText() const; void pushMention(const Mention mention) const; SyntaxHighlighter *m_highlighter = nullptr; + QQuickItem *m_textArea; CompletionModel *m_completionModel = nullptr; + QTextCursor textCursor() const; + void mergeFormatOnWordOrSelection(const QTextCharFormat &format); + void selectLinkText(QTextCursor *cursor) const; + NestedListHelper m_nestedListHelper; + QColor linkColor(); + QColor mLinkColor; + void regenerateColorScheme(); + QUrl m_fileUrl; }; diff --git a/src/libneochat/nestedlisthelper.cpp b/src/libneochat/nestedlisthelper.cpp new file mode 100644 index 000000000..dd153f501 --- /dev/null +++ b/src/libneochat/nestedlisthelper.cpp @@ -0,0 +1,249 @@ +/** + * Nested list helper + * + * SPDX-FileCopyrightText: 2008 Stephen Kelly + * + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +#include "nestedlisthelper_p.h" + +#include +#include +#include +#include + +NestedListHelper::NestedListHelper() +{ + listBottomMargin = 12; + listTopMargin = 12; + listNoMargin = 0; +} + +bool NestedListHelper::handleBeforeKeyPressEvent(QKeyEvent *event, const QTextCursor &cursor) +{ + // Only attempt to handle Backspace while on a list + if ((event->key() != Qt::Key_Backspace) || (!cursor.currentList())) { + return false; + } + + bool handled = false; + + if (!cursor.hasSelection() && cursor.currentList() && event->key() == Qt::Key_Backspace && cursor.atBlockStart()) { + handleOnIndentLess(cursor); + handled = true; + } + + return handled; +} + +bool NestedListHelper::canIndent(const QTextCursor &textCursor) const +{ + if ((textCursor.block().isValid()) + // && ( textEdit->textCursor().block().previous().isValid() ) + ) { + const QTextBlock block = textCursor.block(); + const QTextBlock prevBlock = textCursor.block().previous(); + if (block.textList()) { + if (prevBlock.textList()) { + return block.textList()->format().indent() <= prevBlock.textList()->format().indent(); + } + } else { + return true; + } + } + return false; +} + +bool NestedListHelper::canDedent(const QTextCursor &textCursor) const +{ + QTextBlock thisBlock = textCursor.block(); + QTextBlock nextBlock = thisBlock.next(); + if (thisBlock.isValid()) { + int nextBlockIndent = 0; + if (nextBlock.isValid() && nextBlock.textList()) { + nextBlockIndent = nextBlock.textList()->format().indent(); + } + if (thisBlock.textList()) { + const int thisBlockIndent = thisBlock.textList()->format().indent(); + if (thisBlockIndent >= nextBlockIndent) { + return thisBlockIndent > 0; + } + } + } + return false; +} + +bool NestedListHelper::handleAfterKeyPressEvent(QKeyEvent *event, const QTextCursor &cursor) +{ + // Only attempt to handle Backspace and Return + if ((event->key() != Qt::Key_Backspace) && (event->key() != Qt::Key_Return)) { + return false; + } + + bool handled = false; + + if (!cursor.hasSelection() && cursor.currentList()) { + // Check if we're on the last list item. + // itemNumber is zero indexed + QTextBlock currentBlock = cursor.block(); + if (cursor.currentList()->count() == cursor.currentList()->itemNumber(currentBlock) + 1) { + // Last block in this list, but may have just gained another list below. + if (currentBlock.next().textList()) { + reformatList(cursor.block()); + } + reformatList(cursor.block()); + + // No need to reformatList in this case. reformatList is slow. + if ((event->key() == Qt::Key_Return) || (event->key() == Qt::Key_Backspace)) { + handled = true; + } + } else { + reformatList(cursor.block()); + } + } + return handled; +} + +void NestedListHelper::processList(QTextList *list) +{ + QTextBlock block = list->item(0); + const int thisListIndent = list->format().indent(); + + QTextCursor cursor = QTextCursor(block); + list = cursor.createList(list->format()); + bool processingSubList = false; + while (block.next().textList() != nullptr) { + block = block.next(); + + QTextList *nextList = block.textList(); + const int nextItemIndent = nextList->format().indent(); + if (nextItemIndent < thisListIndent) { + return; + } else if (nextItemIndent > thisListIndent) { + if (processingSubList) { + continue; + } + processingSubList = true; + processList(nextList); + } else { + processingSubList = false; + list->add(block); + } + } + // delete nextList; + // nextList = 0; +} + +void NestedListHelper::reformatList(QTextBlock block) +{ + if (block.textList()) { + const int minimumIndent = block.textList()->format().indent(); + + // Start at the top of the list + while (block.previous().textList() != nullptr) { + if (block.previous().textList()->format().indent() < minimumIndent) { + break; + } + block = block.previous(); + } + + processList(block.textList()); + } +} + +QTextCursor NestedListHelper::topOfSelection(QTextCursor cursor) +{ + if (cursor.hasSelection()) { + cursor.setPosition(qMin(cursor.position(), cursor.anchor())); + } + return cursor; +} + +QTextCursor NestedListHelper::bottomOfSelection(QTextCursor cursor) +{ + if (cursor.hasSelection()) { + cursor.setPosition(qMax(cursor.position(), cursor.anchor())); + } + return cursor; +} + +void NestedListHelper::handleOnIndentMore(const QTextCursor &textCursor) +{ + QTextCursor cursor = textCursor; + + QTextListFormat listFmt; + if (!cursor.currentList()) { + QTextListFormat::Style style; + cursor = topOfSelection(textCursor); + cursor.movePosition(QTextCursor::PreviousBlock); + if (cursor.currentList()) { + style = cursor.currentList()->format().style(); + } else { + cursor = bottomOfSelection(textCursor); + cursor.movePosition(QTextCursor::NextBlock); + + if (cursor.currentList()) { + style = cursor.currentList()->format().style(); + } else { + style = QTextListFormat::ListDisc; + } + } + handleOnBulletType(style, textCursor); + } else { + listFmt = cursor.currentList()->format(); + listFmt.setIndent(listFmt.indent() + 1); + + cursor.createList(listFmt); + reformatList(textCursor.block()); + } +} + +void NestedListHelper::handleOnIndentLess(const QTextCursor &textCursor) +{ + QTextCursor cursor = textCursor; + QTextList *currentList = cursor.currentList(); + if (!currentList) { + return; + } + QTextListFormat listFmt = currentList->format(); + if (listFmt.indent() > 1) { + listFmt.setIndent(listFmt.indent() - 1); + cursor.createList(listFmt); + reformatList(cursor.block()); + } else { + QTextBlockFormat bfmt; + bfmt.setObjectIndex(-1); + cursor.setBlockFormat(bfmt); + reformatList(cursor.block().next()); + } +} + +void NestedListHelper::handleOnBulletType(int styleIndex, const QTextCursor &textCursor) +{ + QTextCursor cursor = textCursor; + if (styleIndex != 0) { + auto style = static_cast(styleIndex); + QTextList *currentList = cursor.currentList(); + QTextListFormat listFmt; + + cursor.beginEditBlock(); + + if (currentList) { + listFmt = currentList->format(); + listFmt.setStyle(style); + currentList->setFormat(listFmt); + } else { + listFmt.setStyle(style); + cursor.createList(listFmt); + } + + cursor.endEditBlock(); + } else { + QTextBlockFormat bfmt; + bfmt.setObjectIndex(-1); + cursor.setBlockFormat(bfmt); + } + + reformatList(textCursor.block()); +} diff --git a/src/libneochat/nestedlisthelper_p.h b/src/libneochat/nestedlisthelper_p.h new file mode 100644 index 000000000..af29bdf79 --- /dev/null +++ b/src/libneochat/nestedlisthelper_p.h @@ -0,0 +1,114 @@ +/** + * Nested list helper + * + * SPDX-FileCopyrightText: 2008 Stephen Kelly + * + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +#pragma once + +class QKeyEvent; +class QTextCursor; +class QTextBlock; +class QTextList; + +/** + * + * @short Helper class for automatic handling of nested lists in a text edit + * + * + * @author Stephen Kelly + * @since 4.1 + * @internal + */ +class NestedListHelper +{ +public: + /** + * Create a helper + * + * @param te The text edit object to handle lists in. + */ + NestedListHelper(); + + /** + * + * Handles a key press before it is processed by the text edit widget. + * + * Currently this causes a backspace at the beginning of a line or with a + * multi-line selection to decrease the nesting level of the list. + * + * @param event The event to be handled + * @return Whether the event was completely handled by this method. + */ + [[nodiscard]] bool handleBeforeKeyPressEvent(QKeyEvent *event, const QTextCursor &cursor); + + /** + * + * Handles a key press after it is processed by the text edit widget. + * + * Currently this causes a Return at the end of the last list item, or + * a Backspace after the last list item to recalculate the spacing + * between the list items. + * + * @param event The event to be handled + * @return Whether the event was completely handled by this method. + */ + bool handleAfterKeyPressEvent(QKeyEvent *event, const QTextCursor &cursor); + + /** + * Increases the indent (nesting level) on the current list item or selection. + */ + void handleOnIndentMore(const QTextCursor &textCursor); + + /** + * Decreases the indent (nesting level) on the current list item or selection. + */ + void handleOnIndentLess(const QTextCursor &textCursor); + + /** + * Changes the style of the current list or creates a new list with + * the specified style. + * + * @param styleIndex The QTextListStyle of the list. + */ + void handleOnBulletType(int styleIndex, const QTextCursor &textCursor); + + /** + * @brief Check whether the current item in the list may be indented. + * + * An list item must have an item above it on the same or greater level + * if it can be indented. + * + * Also, a block which is currently part of a list can be indented. + * + * @sa canDedent + * + * @return Whether the item can be indented. + */ + [[nodiscard]] bool canIndent(const QTextCursor &textCursor) const; + + /** + * \brief Check whether the current item in the list may be dedented. + * + * An item may be dedented if it is part of a list. Otherwise it can't be. + * + * @sa canIndent + * + * @return Whether the item can be dedented. + */ + [[nodiscard]] bool canDedent(const QTextCursor &textCursor) const; + +private: + [[nodiscard]] QTextCursor topOfSelection(QTextCursor cursor); + [[nodiscard]] QTextCursor bottomOfSelection(QTextCursor cursor); + void processList(QTextList *list); + void reformatList(QTextBlock block); + + int listBottomMargin; + int listTopMargin; + int listNoMargin; +}; + +//@endcond diff --git a/src/app/qml/OpenFileDialog.qml b/src/libneochat/qml/OpenFileDialog.qml similarity index 100% rename from src/app/qml/OpenFileDialog.qml rename to src/libneochat/qml/OpenFileDialog.qml diff --git a/src/libneochat/texthandler.cpp b/src/libneochat/texthandler.cpp index d2e351acd..bde1aaec3 100644 --- a/src/libneochat/texthandler.cpp +++ b/src/libneochat/texthandler.cpp @@ -52,8 +52,8 @@ void TextHandler::setData(const QString &string) QString TextHandler::handleSendText() { m_pos = 0; - m_dataBuffer = markdownToHTML(m_data); - m_dataBuffer = customMarkdownToHtml(m_dataBuffer); + m_dataBuffer = customMarkdownToHtml(m_data); + m_dataBuffer = markdownToHTML(m_dataBuffer); m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType); @@ -802,6 +802,9 @@ QString TextHandler::customMarkdownToHtml(const QString &stringIn) // strikethrough processSyntax(u"~~"_s, u""_s, u""_s); + // underline + processSyntax(u"_"_s, u""_s, u""_s); + return buffer; } diff --git a/src/libneochat/utils.h b/src/libneochat/utils.h index 09e6c1145..d5bbf3c47 100644 --- a/src/libneochat/utils.h +++ b/src/libneochat/utils.h @@ -73,6 +73,7 @@ namespace TextRegex static const QRegularExpression endTagType{u"[> /]"_s}; static const QRegularExpression endAttributeType{u"[> ]"_s}; static const QRegularExpression attributeData{u"['\"](.*?)['\"]"_s}; +static const QRegularExpression htmlBodyContent{u"]*>(.*?)"_s, QRegularExpression::DotMatchesEverythingOption}; static const QRegularExpression removeReply{u"> <.*?>.*?\\n\\n"_s, QRegularExpression::DotMatchesEverythingOption}; static const QRegularExpression removeRichReply{u".*?"_s, QRegularExpression::DotMatchesEverythingOption}; static const QRegularExpression codePill{u"
]*>(.*?)
"_s, QRegularExpression::DotMatchesEverythingOption};