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
This commit is contained in:
parent
7d9414ef93
commit
97da863d70
|
@ -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){
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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<HighlightingRule> highlightingRules{5};
|
||||
QVector<HighlightingRule> highlightingRules{7};
|
||||
|
||||
QTextCharFormat singlelineBoldFormat;
|
||||
QTextCharFormat singleLineItalicFormat;
|
||||
QTextCharFormat codeFormat;
|
||||
QTextCharFormat singleLineStrikeThroughFormat;
|
||||
QTextCharFormat hyperlinkFormat;
|
||||
QTextCharFormat highlightedHyperlinkFormat;
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
#include "StatusQ/statussyntaxhighlighter.h"
|
||||
|
||||
#include <QQuickTextDocument>
|
||||
#include <QUrl>
|
||||
|
||||
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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: `<a href="%1".*?<span.*?>(.*?)<\/span><\/a>`
|
||||
readonly property string selectLinkWithoutAnchors: "%1(?=<| )(?![^<]*<\/span><\/a>)(?!\")"
|
||||
readonly property string selectHyperlink: `<a href=(?:(?!<a href=).)*?%1<\/span><\/a>`
|
||||
readonly property string hyperlinkFormat: `<a href="%1">%1</a>`
|
||||
readonly property string hoveredHyperlinkFormat: `<a href="%2" style="background-color: %1">%2</a>`.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("(<br/>|<br />| )+(</span></a>)", "$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, '>')
|
||||
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, '$$$$');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue