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:
Alex Jbanca 2023-11-07 13:32:21 +02:00 committed by Alex Jbanca
parent 7d9414ef93
commit 97da863d70
9 changed files with 195 additions and 429 deletions

View File

@ -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){

View File

@ -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 },

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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, '&amp;')
result = result.replace(/</g, '&lt;')
result = result.replace(/>/g, '&gt;')
result = result.replace(/\"/g, '&quot;')
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, '$$$$');
}
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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