From 97da863d7038da7b72dcc4fce11b4e567f5623c9 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 7 Nov 2023 13:32:21 +0200 Subject: [PATCH] fix: Use StatusSynthaxHighlighter to format the hyperlinks in the chat input 1. Move the hyperlinks formatting from qml to c++ (StatusSynthaxHighlighter) 2. Update StatusChatInputPage 3. Update tests --- storybook/pages/StatusChatInputPage.qml | 13 +- .../qmlTests/tests/tst_StatusChatInput.qml | 120 +----------- .../include/StatusQ/statussyntaxhighlighter.h | 38 +++- ui/StatusQ/src/statussyntaxhighlighter.cpp | 132 ++++++++++++- .../AppLayouts/Chat/views/ChatColumnView.qml | 11 +- ui/imports/shared/status/StatusChatInput.qml | 15 +- .../status/TextEditHyperlinksFormatter.qml | 117 ------------ ui/imports/utils/UndoStackManager.qml | 177 ------------------ ui/imports/utils/qmldir | 1 - 9 files changed, 195 insertions(+), 429 deletions(-) delete mode 100644 ui/imports/shared/status/TextEditHyperlinksFormatter.qml delete mode 100644 ui/imports/utils/UndoStackManager.qml diff --git a/storybook/pages/StatusChatInputPage.qml b/storybook/pages/StatusChatInputPage.qml index 36af98ea30..204f05b9d3 100644 --- a/storybook/pages/StatusChatInputPage.qml +++ b/storybook/pages/StatusChatInputPage.qml @@ -9,6 +9,8 @@ import utils 1.0 import shared.status 1.0 import shared.stores 1.0 +import StatusQ.Core.Utils 0.1 + SplitView { id: root @@ -95,6 +97,10 @@ SplitView { property var globalUtils: globalUtilsMock.globalUtils property string unformattedText: chatInput.textInput.getText(0, chatInput.textInput.length) + readonly property ModelChangeTracker urlsModelChangeTracker: ModelChangeTracker { + model: fakeLinksModel + } + onUnformattedTextChanged: { textEditConnection.enabled = false d.loadLinkPreviews(unformattedText) @@ -112,7 +118,10 @@ SplitView { enabled: enabledCheckBox.checked linkPreviewModel: fakeLinksModel - urlsModel: fakeLinksModel + urlsList: { + urlsModelChangeTracker.revision + ModelUtils.modelToFlatArray(fakeLinksModel, "url") + } askToEnableLinkPreview: askToEnableLinkPreviewSwitch.checked onAskToEnableLinkPreviewChanged: { if(askToEnableLinkPreview) { @@ -167,7 +176,7 @@ SplitView { loadLinkPreviews(chatInputLoader.item ? chatInputLoader.item.unformattedText : "") } function loadLinkPreviews(text) { - var words = text.split(" ") + var words = text.split(/\s+/) fakeLinksModel.clear() words.forEach(function(word){ diff --git a/storybook/qmlTests/tests/tst_StatusChatInput.qml b/storybook/qmlTests/tests/tst_StatusChatInput.qml index da3f91071c..89f062d47e 100644 --- a/storybook/qmlTests/tests/tst_StatusChatInput.qml +++ b/storybook/qmlTests/tests/tst_StatusChatInput.qml @@ -288,124 +288,6 @@ Item { verify(textWithPubKey.includes("@0x0JohnDoe"), "Expected @pubKey to replace @contactName") } - -// Scenario: The user can undo and redo text changes -// Given the user has typed text in StatusChatInput -// """ -// 123456789 -// """ -// When the user hits undo shortcut -// Then the last text change is reverted -// And the cursor position is reverted -// And the user can redo the text change -// And the cursor position is restored - function test_user_can_undo_and_redo_text_changes() { - testHelper.when_text_is_typed(statusChatInputKeyboardInputExpectedAsText, - "123456789", (typedText) => {}) - - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "12345678") - compare(controlUnderTest.textInput.cursorPosition, 8) - - keySequence(StandardKey.Redo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 9) - - keySequence(StandardKey.Undo) - keySequence(StandardKey.Undo) - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "123456") - compare(controlUnderTest.textInput.cursorPosition, 6) - - keySequence(StandardKey.Redo) - keySequence(StandardKey.Redo) - keySequence(StandardKey.Redo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 9) - - keyClick(Qt.Key_Backspace) - compare(controlUnderTest.getPlainText(), "12345678") - compare(controlUnderTest.textInput.cursorPosition, 8) - - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 9) - - keyClick(Qt.Key_Backspace) - compare(controlUnderTest.getPlainText(), "12345678") - compare(controlUnderTest.textInput.cursorPosition, 8) - - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 9) - } - -// Scenario: The user can undo and redo text changes with selection -// Given the user has typed text in StatusChatInput -// """ -// 123456789 -// """ -// And cursor is moved to the middle of the text, at position 5 -// And the user selects and deletes text from position 5 to 3 -// When the user hits undo shortcut -// Then the last text change is reverted - - function test_user_can_undo_and_redo_text_changes_with_selection() { - testHelper.when_text_is_typed(statusChatInputKeyboardInputExpectedAsText, - "123456789", (typedText) => {}) - - controlUnderTest.textInput.cursorPosition = 5 - controlUnderTest.textInput.select(5, 3) - keySequence(StandardKey.Delete) - compare(controlUnderTest.getPlainText(), "1236789") - - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 5) - - - controlUnderTest.textInput.select(5, 3) - keySequence(StandardKey.Delete) - compare(controlUnderTest.getPlainText(), "1236789") - - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 5) - - controlUnderTest.textInput.cursorPosition = 4 - controlUnderTest.textInput.select(4, 2) - keySequence(StandardKey.Delete) - compare(controlUnderTest.getPlainText(), "1256789") - - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 4) - } - -// Scenario: The user can undo and redo the entire text -// Given the user has typed text in StatusChatInput -// """ -// 123456789 -// """ -// And the user selects and deletes the entire text -// When the user hits undo shortcut -// Then the last text change is reverted - function test_user_can_undo_and_redo_the_entire_text() { - testHelper.when_text_is_typed(statusChatInputKeyboardInputExpectedAsText, - "123456789", (typedText) => {}) - - controlUnderTest.textInput.select(0, 9) - keySequence(StandardKey.Delete) - compare(controlUnderTest.getPlainText(), "") - - keySequence(StandardKey.Undo) - compare(controlUnderTest.getPlainText(), "123456789") - compare(controlUnderTest.textInput.cursorPosition, 9) - - keySequence(StandardKey.Redo) - compare(controlUnderTest.getPlainText(), "") - compare(controlUnderTest.textInput.cursorPosition, 0) - } } TestCase { @@ -457,7 +339,7 @@ Item { function test_standard_key_shortcuts_data() { return [ { tag: "Delete", key: StandardKey.Delete, initialCursorPosition: 0, initialSelectionStart: 0, initialSelectionEnd: 0, expectIdenticalPlainText: true }, - { tag: "Undo", key: StandardKey.Undo, initialCursorPosition: 0, initialSelectionStart: 0, initialSelectionEnd: 0, expectIdenticalPlainText: false }, + { tag: "Undo", key: StandardKey.Undo, initialCursorPosition: 0, initialSelectionStart: 0, initialSelectionEnd: 0, expectIdenticalPlainText: true }, { tag: "Redo", key: StandardKey.Redo, initialCursorPosition: 0, initialSelectionStart: 0, initialSelectionEnd: 0, expectIdenticalPlainText: true }, { tag: "SelectAll", key: StandardKey.SelectAll, initialCursorPosition: 0, initialSelectionStart: 0, initialSelectionEnd: 0 , expectIdenticalPlainText: true }, { tag: "Bold", key: StandardKey.Bold, initialCursorPosition: 0, initialSelectionStart: 0, initialSelectionEnd: 3, expectIdenticalPlainText: false, expectIdenticalSelection: false }, diff --git a/ui/StatusQ/include/StatusQ/statussyntaxhighlighter.h b/ui/StatusQ/include/StatusQ/statussyntaxhighlighter.h index 44515d826e..170c026b06 100644 --- a/ui/StatusQ/include/StatusQ/statussyntaxhighlighter.h +++ b/ui/StatusQ/include/StatusQ/statussyntaxhighlighter.h @@ -17,6 +17,14 @@ class StatusSyntaxHighlighter : public QSyntaxHighlighter, public QQmlParserStat codeBackgroundColorChanged) Q_PROPERTY(QColor codeForegroundColor READ codeForegroundColor WRITE setCodeForegroundColor NOTIFY codeForegroundColorChanged) + Q_PROPERTY(QColor hyperlinkColor READ hyperlinkColor WRITE setHyperlinkColor NOTIFY + hyperlinkColorChanged) + Q_PROPERTY(QColor hyperlinkHoverColor READ hyperlinkHoverColor WRITE setHyperlinkHoverColor NOTIFY + hyperlinkHoverColorChanged) + Q_PROPERTY(QStringList hyperlinks READ hyperlinks WRITE setHyperlinks NOTIFY + hyperlinksChanged) + Q_PROPERTY(QString highlightedHyperlink READ highlightedHyperlink WRITE setHighlightedHyperlink NOTIFY + highlightedHyperlinkChanged) Q_INTERFACES(QQmlParserStatus) @@ -35,6 +43,10 @@ signals: void quickTextDocumentChanged(); void codeBackgroundColorChanged(); void codeForegroundColorChanged(); + void hyperlinkColorChanged(); + void hyperlinkHoverColorChanged(); + void hyperlinksChanged(); + void highlightedHyperlinkChanged(); private: QQuickTextDocument* m_quicktextdocument{nullptr}; @@ -47,15 +59,39 @@ private: QColor codeForegroundColor() const; void setCodeForegroundColor(const QColor& color); + QColor m_hyperlinkColor; + QColor hyperlinkColor() const; + void setHyperlinkColor(const QColor& color); + + QColor m_hyperlinkHoverColor; + QColor hyperlinkHoverColor() const; + void setHyperlinkHoverColor(const QColor& color); + + QStringList m_hyperlinks; + QStringList hyperlinks() const; + void setHyperlinks(const QStringList& hyperlinks); + QRegularExpression hyperlinksRegularExpression() const; + + QString m_highlightedHyperlink; + QString highlightedHyperlink() const; + void setHighlightedHyperlink(const QString& hyperlink); + QRegularExpression highlightedHyperlinkRegularExpression() const; + + QStringList getPossibleUrlFormats(const QUrl& url) const; + QRegularExpression buildHyperlinkRegex(QStringList hyperlinks) const; + struct HighlightingRule { QRegularExpression pattern; + QRegularExpression::MatchType matchType{QRegularExpression::PartialPreferCompleteMatch}; QTextCharFormat format; }; - QVector highlightingRules{5}; + QVector highlightingRules{7}; QTextCharFormat singlelineBoldFormat; QTextCharFormat singleLineItalicFormat; QTextCharFormat codeFormat; QTextCharFormat singleLineStrikeThroughFormat; + QTextCharFormat hyperlinkFormat; + QTextCharFormat highlightedHyperlinkFormat; }; diff --git a/ui/StatusQ/src/statussyntaxhighlighter.cpp b/ui/StatusQ/src/statussyntaxhighlighter.cpp index 89b8733f31..e0e68358a0 100644 --- a/ui/StatusQ/src/statussyntaxhighlighter.cpp +++ b/ui/StatusQ/src/statussyntaxhighlighter.cpp @@ -1,6 +1,7 @@ #include "StatusQ/statussyntaxhighlighter.h" #include +#include StatusSyntaxHighlighter::StatusSyntaxHighlighter(QObject* parent) : QSyntaxHighlighter(parent) @@ -47,14 +48,57 @@ void StatusSyntaxHighlighter::componentComplete() rule.format = codeFormat; highlightingRules.append(rule); //CODEBLOCK + + //HYPERLINKS + //QRegularExpression to match any hyperlink in m_hyperlinks + hyperlinkFormat.setForeground(m_hyperlinkColor); + rule.pattern = hyperlinksRegularExpression(); + rule.format = hyperlinkFormat; + rule.matchType = QRegularExpression::NormalMatch; + highlightingRules.append(rule); + + const int hyperlinksRuleIndex = highlightingRules.size() - 1; + + //HIGHLIGHTED + highlightedHyperlinkFormat.setForeground(m_hyperlinkColor); + highlightedHyperlinkFormat.setBackground(m_hyperlinkHoverColor); + rule.pattern = highlightedHyperlinkRegularExpression(); + rule.format = highlightedHyperlinkFormat; + rule.matchType = QRegularExpression::NormalMatch; + highlightingRules.append(rule); + + const int highlightedHyperlinkRuleIndex = highlightingRules.size() - 1; + + connect(this, &StatusSyntaxHighlighter::hyperlinksChanged, this, [hyperlinksRuleIndex, this](){ + highlightingRules[hyperlinksRuleIndex].pattern = hyperlinksRegularExpression(); + rehighlight(); + }); + connect(this, &StatusSyntaxHighlighter::hyperlinkColorChanged, this, [hyperlinksRuleIndex, this](){ + hyperlinkFormat.setForeground(m_hyperlinkColor); + highlightedHyperlinkFormat.setForeground(m_hyperlinkColor); + highlightingRules[hyperlinksRuleIndex].format = hyperlinkFormat; + rehighlight(); + }); + + connect(this, &StatusSyntaxHighlighter::highlightedHyperlinkChanged, this, [highlightedHyperlinkRuleIndex, this](){ + highlightingRules[highlightedHyperlinkRuleIndex].pattern = highlightedHyperlinkRegularExpression(); + rehighlight(); + }); + connect(this, &StatusSyntaxHighlighter::hyperlinkHoverColorChanged, this, [highlightedHyperlinkRuleIndex, this](){ + highlightedHyperlinkFormat.setBackground(m_hyperlinkHoverColor); + highlightingRules[highlightedHyperlinkRuleIndex].format = highlightedHyperlinkFormat; + rehighlight(); + }); } void StatusSyntaxHighlighter::highlightBlock(const QString& text) { for(const HighlightingRule& rule : qAsConst(highlightingRules)) { + if(rule.pattern.pattern() == QStringLiteral("")) continue; + QRegularExpressionMatchIterator matchIterator = - rule.pattern.globalMatch(text, 0, QRegularExpression::PartialPreferCompleteMatch); + rule.pattern.globalMatch(text, 0, rule.matchType); while(matchIterator.hasNext()) { const QRegularExpressionMatch match = matchIterator.next(); @@ -101,3 +145,89 @@ void StatusSyntaxHighlighter::setCodeForegroundColor(const QColor& color) m_codeForegroundColor = color; emit codeForegroundColorChanged(); } + +QColor StatusSyntaxHighlighter::hyperlinkColor() const +{ + return m_hyperlinkColor; +} + +void StatusSyntaxHighlighter::setHyperlinkColor(const QColor& color) +{ + if(color == m_hyperlinkColor) return; + m_hyperlinkColor = color; + emit hyperlinkColorChanged(); +} + +QColor StatusSyntaxHighlighter::hyperlinkHoverColor() const +{ + return m_hyperlinkHoverColor; +} + +void StatusSyntaxHighlighter::setHyperlinkHoverColor(const QColor& color) +{ + if(color == m_hyperlinkHoverColor) return; + m_hyperlinkHoverColor = color; + emit hyperlinkHoverColorChanged(); +} + +QStringList StatusSyntaxHighlighter::hyperlinks() const +{ + return m_hyperlinks; +} + +void StatusSyntaxHighlighter::setHyperlinks(const QStringList& hyperlinks) +{ + if(hyperlinks == m_hyperlinks) return; + m_hyperlinks = hyperlinks; + emit hyperlinksChanged(); +} +QString StatusSyntaxHighlighter::highlightedHyperlink() const +{ + return m_highlightedHyperlink; +} + +void StatusSyntaxHighlighter::setHighlightedHyperlink(const QString& hyperlink) +{ + if(hyperlink == m_highlightedHyperlink) return; + m_highlightedHyperlink = hyperlink; + emit highlightedHyperlinkChanged(); +} + +QRegularExpression StatusSyntaxHighlighter::highlightedHyperlinkRegularExpression() const +{ + const auto possibleUrlFormats = getPossibleUrlFormats(QUrl(m_highlightedHyperlink)); + return buildHyperlinkRegex(possibleUrlFormats); +} + +QRegularExpression StatusSyntaxHighlighter::hyperlinksRegularExpression() const +{ + QStringList hyperlinks; + for(const QString& hyperlink : qAsConst(m_hyperlinks)) + { + const auto possibleUrlFormats = getPossibleUrlFormats(QUrl(hyperlink)); + hyperlinks.append(possibleUrlFormats); + } + + return buildHyperlinkRegex(hyperlinks); +} + +QStringList StatusSyntaxHighlighter::getPossibleUrlFormats(const QUrl& url) const +{ + QStringList result; + result.append(QRegularExpression::escape(url.toString())); + result.append(QRegularExpression::escape(url.toString(QUrl::EncodeUnicode))); + result.append(QRegularExpression::escape(url.toString(QUrl::FullyEncoded))); + return result; +} + +QRegularExpression StatusSyntaxHighlighter::buildHyperlinkRegex(QStringList hyperlinks) const +{ + hyperlinks.removeAll(QString()); + + if(hyperlinks.isEmpty()) + return QRegularExpression("(?!)"); + QString matchHyperlinks = QStringLiteral("(?:^|(?<=\\s))(") + hyperlinks.join("|") + QStringLiteral(")(?:(?=\\s)|$)"); + auto regex = QRegularExpression(matchHyperlinks, QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption | QRegularExpression::MultilineOption); + regex.optimize(); + return regex; +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Chat/views/ChatColumnView.qml b/ui/app/AppLayouts/Chat/views/ChatColumnView.qml index b880d25f41..67d4917ebe 100644 --- a/ui/app/AppLayouts/Chat/views/ChatColumnView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatColumnView.qml @@ -80,6 +80,15 @@ Item { id: d readonly property var activeChatContentModule: d.getChatContentModule(root.activeChatId) + readonly property var urlsList: { + urlsModelChangeTracker.revision + ModelUtils.modelToFlatArray(d.activeChatContentModule.inputAreaModule.urlsModel, "url") + } + + readonly property ModelChangeTracker urlsModelChangeTracker: ModelChangeTracker { + model: d.activeChatContentModule.inputAreaModule.urlsModel + } + readonly property UsersStore activeUsersStore: UsersStore { usersModule: !!d.activeChatContentModule ? d.activeChatContentModule.usersModule : null } @@ -255,7 +264,7 @@ Item { store: root.rootStore usersStore: d.activeUsersStore linkPreviewModel: d.activeChatContentModule.inputAreaModule.linkPreviewModel - urlsModel: d.activeChatContentModule.inputAreaModule.urlsModel + urlsList: d.urlsList askToEnableLinkPreview: { if(!d.activeChatContentModule || !d.activeChatContentModule.inputAreaModule || !d.activeChatContentModule.inputAreaModule.preservedProperties) return false diff --git a/ui/imports/shared/status/StatusChatInput.qml b/ui/imports/shared/status/StatusChatInput.qml index 2540be7d17..5aa1cc4b20 100644 --- a/ui/imports/shared/status/StatusChatInput.qml +++ b/ui/imports/shared/status/StatusChatInput.qml @@ -65,7 +65,7 @@ Rectangle { property var linkPreviewModel: null - property var urlsModel: null + property var urlsList: null property bool askToEnableLinkPreview: false @@ -122,7 +122,6 @@ Rectangle { function setText(text) { textInput.clear() - hyperlinksFormatter.undoStackManager.clear() textInput.append(text) } @@ -1355,6 +1354,10 @@ Rectangle { quickTextDocument: messageInputField.textDocument codeBackgroundColor: Style.current.codeBackground codeForegroundColor: Style.current.textColor + hyperlinks: control.urlsList + hyperlinkColor: Theme.palette.primaryColor1 + highlightedHyperlink: linkPreviewArea.hoveredUrl + hyperlinkHoverColor: Theme.palette.primaryColor3 } MouseArea { anchors.fill: parent @@ -1364,14 +1367,6 @@ Rectangle { } } - TextEditHyperlinksFormatter { - id: hyperlinksFormatter - textEdit: messageInputField - urlModel: control.urlsModel - highlightUrl: linkPreviewArea.hoveredUrl - enabled: messageInputField.enabled && messageInputField.textFormat == TextEdit.RichText - } - Shortcut { enabled: messageInputField.activeFocus sequence: StandardKey.Bold diff --git a/ui/imports/shared/status/TextEditHyperlinksFormatter.qml b/ui/imports/shared/status/TextEditHyperlinksFormatter.qml deleted file mode 100644 index 003f88ea76..0000000000 --- a/ui/imports/shared/status/TextEditHyperlinksFormatter.qml +++ /dev/null @@ -1,117 +0,0 @@ -import QtQuick 2.14 - -import StatusQ.Core.Theme 0.1 -import StatusQ.Core.Utils 0.1 as SQUtils - -import utils 1.0 - - -/* This component will format the urls in TextEdit and add the proper anchor tags - It receives the TextEdit component and the urls model containing the urls to format. The URL detection needs to be done outside of this component - - Due to the Qt limitations (the undo stack is cleared when editing the internal formatted text) this component will install a custom undo stack manager. -*/ - -QtObject { - id: root - - /* The TextEdit component containing the text to format - The textEdit is required to be able to access the text and the cursor position - */ - required property TextEdit textEdit - /* The url to highlight - The url is required to be able to highlight URLs - */ - required property string highlightUrl - /* The model containing the urls to format - All the urls in the model will be formatted - Eg: [ { url: "https://www.google.com" }, { url: "https://www.google.ro" } ] - */ - required property var urlModel - - property bool enabled: true - - /* Custom undo stack manager. This is needed because the hyperlinks formatter will alter the internal rich text of the TextEdit - and the standard undo stack manager will clear the stack on each change. - */ - readonly property UndoStackManager undoStackManager: UndoStackManager { - textEdit: root.textEdit - enabled: root.enabled - } - - /* Internal component to format the hyperlinks - This component is used to format the hyperlinks and add the proper anchor tags - */ - readonly property Instantiator handlers: Instantiator { - id: hyperlinksFormatter - - readonly property string selectLinkBetweenAnchors: `(.*?)<\/span><\/a>` - readonly property string selectLinkWithoutAnchors: "%1(?=<| )(?![^<]*<\/span><\/a>)(?!\")" - readonly property string selectHyperlink: `<\/a>` - readonly property string hyperlinkFormat: `%1` - readonly property string hoveredHyperlinkFormat: `%2`.arg(Theme.palette.primaryColor3) - - active: root.enabled - model: root.urlModel - - delegate: QtObject { - id: hyperlinkDelegate - // Model - required property string url - - // Helper properties - readonly property string escapedURLforRegex: escapeRegExp(url) // The url needs to be escaped to be used in regex - readonly property string escapedUrlForReplacement: escapeReplacement(url) // The url needs to be escaped to be used in the replacement string - - readonly property bool highlighted: url === root.highlightUrl - - // The hyperlink style can change when the preview is highlighted - readonly property string hyperlinkToInsert: highlighted ? hyperlinksFormatter.hoveredHyperlinkFormat.arg(hyperlinkDelegate.escapedUrlForReplacement) : - hyperlinksFormatter.hyperlinkFormat.arg(hyperlinkDelegate.escapedUrlForReplacement) - - // Behavior - - // Change the link style when anchoredHyperlink changes - onHyperlinkToInsertChanged: replaceAll(hyperlinksFormatter.selectHyperlink.arg(hyperlinkDelegate.escapedURLforRegex), hyperlinkToInsert) - // Handling text changes is needed to detect spaces inside hyperlink tags and move them outside of the tag - // And to detect new duplicate links to add proper anchor tags - property Connections textConnection: Connections { - target: root.textEdit - function onTextChanged() { - replaceAll("(
|
| )+()", "$2$1") // Move spaces outside of the hyperlink tag - replaceAll(hyperlinksFormatter.selectLinkWithoutAnchors.arg(hyperlinkDelegate.escapedURLforRegex), hyperlinkDelegate.hyperlinkToInsert) - } - } - // link detected -> add the hyperlink - Component.onCompleted: replaceAll(hyperlinksFormatter.selectLinkWithoutAnchors.arg(hyperlinkDelegate.escapedURLforRegex), hyperlinkDelegate.hyperlinkToInsert) - // link removed. Can happen when the link is removed or replaced in the input with another link - Component.onDestruction: replaceAll(hyperlinksFormatter.selectLinkBetweenAnchors.arg(hyperlinkDelegate.escapedURLforRegex), "$1") - - // Helper functions - function replaceAll(from, to) { - const newText = root.textEdit.text.replace(new RegExp(from, 'g'), to) - if(newText !== root.textEdit.text) { - textConnection.enabled = false - const cursorPosition = root.textEdit.cursorPosition - root.textEdit.text = newText - root.textEdit.cursorPosition = cursorPosition - textConnection.enabled = true - } - } - - function escapeRegExp(string) { - let result = string.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); // $& means the whole matched string - result = result.replace(/&(?!amp;)/g, '&') - result = result.replace(//g, '>') - result = result.replace(/\"/g, '"') - return decodeURI(result) - } - - function escapeReplacement(string) { - let result = decodeURI(string) // decode the url to be able to use it in the regex - return result.replace(/\$/g, '$$$$'); - } - } - } -} diff --git a/ui/imports/utils/UndoStackManager.qml b/ui/imports/utils/UndoStackManager.qml deleted file mode 100644 index 14274cfcca..0000000000 --- a/ui/imports/utils/UndoStackManager.qml +++ /dev/null @@ -1,177 +0,0 @@ -import QtQuick 2.15 - -/* - Custom stack-based undo/redo implementation for TextEdit that works with formatted text. - - Usage: - TextEdit { - id: textEdit - text: "Hello world" - onCustomEvent: undoStack.clear() - } - - UndoRedoStack { - id: undoStack - textEdit: textEdit - enabled: true - maxStackSize: 100 - } -*/ - -Item { - id: root - - /* - The TextEdit to apply undo/redo to. The stack manager will be installed automatically on this textEdit - */ - required property TextEdit textEdit - /* - The maximum stack size - Once the maximum stack size is reached, the stack will be reduced to half its size by removing every second item - As a result the undo/redo will be less precise, jumping back/forward by 2 steps instead of 1 - The first item in the stack will always be kept - */ - property int maxStackSize: 100 - /* - Function used to clear the stack - This function will be called automatically when the TextEdit component changes or when the enabled property changes - */ - function clear() { - d.undoStack = [] - d.redoStack = [] - d.previousFormattedText = "" - d.previousText = "" - } - - /* - Undo the last action - count: The number of actions to redo - */ - function undo(count = 1) { - if(d.undoStack.length == 0 || count <= 0) { - return - } - - for (var i = 0; i < count; i++) { - if(d.undoStack.length == 0) { - return - } - - const lastAction = d.undoStack.pop() - d.redoStack.push(lastAction) - lastAction.undo() - } - } - - /* - Redo the last action - count: The number of actions to redo - */ - function redo(count = 1) { - if(d.redoStack.length == 0 || count <= 0) { - return - } - - for (var i = 0; i < count; i++) { - if(d.redoStack.length == 0) { - return - } - const lastAction = d.redoStack.pop() - d.undoStack.push(lastAction) - lastAction.redo() - } - } - - onTextEditChanged: { - clear() - textEdit.Keys.forwardTo.push(root) - } - onEnabledChanged: clear() - - Keys.enabled: root.enabled - Keys.onPressed: { - if(event.matches(StandardKey.Undo)) { - undo(event.isAutoRepeat ? 2 : 1) - event.accepted = true - return - } - - if(event.matches(StandardKey.Redo)) { - redo(event.isAutoRepeat ? 2 : 1) - event.accepted = true - return - } - } - - readonly property QtObject d: QtObject { - property var undoStack: [] - property var redoStack: [] - property string previousFormattedText: "" - property string previousText: "" - - property bool aboutToChangeText: false - - function reduceUndoStack() { - if(d.undoStack.length <= root.maxStackSize) { - return - } - - const newStackSize = Math.ceil(root.maxStackSize / 2) - for(var i = 1; i <= newStackSize; i++) { - d.undoStack.splice(i, Math.ceil(root.maxStackSize / newStackSize)) - } - } - - function extrapolatePreviousCursorPosition() { - const previousText = root.textEdit.getText(0, root.textEdit.length) - const previousCursorPosition = root.textEdit.cursorPosition - root.textEdit.length + d.previousText.length - return previousCursorPosition - } - - readonly property Connections textChangedConnection: Connections { - target: root.textEdit - enabled: root.enabled && !d.aboutToChangeText - function onTextChanged() { - const unformattedText = root.textEdit.getText(0, root.textEdit.length) - - if(d.previousText !== unformattedText) { - const newFormattedText = root.textEdit.text - const newCursorPosition = root.textEdit.cursorPosition - - const previousFormattedTextCopy = d.previousFormattedText - const previousCursorPosition = d.extrapolatePreviousCursorPosition() - - d.undoStack.push({ - undo: function() { - d.aboutToChangeText = true - //restore - root.textEdit.text = previousFormattedTextCopy - root.textEdit.cursorPosition = previousCursorPosition - //snapshot - d.previousText = root.textEdit.getText(0, root.textEdit.length) - d.previousFormattedText = root.textEdit.text - - d.aboutToChangeText = false - }, - redo: function() { - d.aboutToChangeText = true - //restore - root.textEdit.text = newFormattedText - root.textEdit.cursorPosition = newCursorPosition - //snapshot - d.previousText = root.textEdit.getText(0, root.textEdit.length) - d.previousFormattedText = root.textEdit.text - - d.aboutToChangeText = false - } - }) - - d.reduceUndoStack() - - d.previousText = unformattedText - d.previousFormattedText = newFormattedText - } - } - } - } -} diff --git a/ui/imports/utils/qmldir b/ui/imports/utils/qmldir index 2a48597abf..c9f424894f 100644 --- a/ui/imports/utils/qmldir +++ b/ui/imports/utils/qmldir @@ -1,6 +1,5 @@ Audio 1.0 Audio.qml Tracer 1.0 Tracer.qml -UndoStackManager 1.0 UndoStackManager.qml singleton Backpressure 1.0 Backpressure/Backpressure.qml singleton Constants 1.0 Constants.qml singleton Global 1.0 Global.qml