feat: Generate link previews in StatusChatInput - Small updates + Add basic zoom to Storybook InspectionWindow

This commit is contained in:
Alex Jbanca 2023-09-26 17:20:59 +03:00 committed by Alex Jbanca
parent d32af78bc0
commit 422bb2c064
18 changed files with 368 additions and 143 deletions

View File

@ -90,37 +90,34 @@ proc getChatId*(self: Controller): string =
proc belongsToCommunity*(self: Controller): bool =
return self.belongsToCommunity
proc gatherLinkPreviews(self: Controller, messsageText: string): seq[LinkPreview] =
let urls = self.messageService.getTextUrls(messsageText)
let linkPreviews = self.linkPreviewCache.linkPreviewsSeq(urls)
return filter(linkPreviews, proc(x: LinkPreview): bool = x.hostname.len > 0)
proc sendImages*(self: Controller,
imagePathsAndDataJson: string,
msg: string,
replyTo: string,
preferredUsername: string = ""): string =
preferredUsername: string = "",
linkPreviews: seq[LinkPreview]): string =
self.chatService.sendImages(
self.chatId,
imagePathsAndDataJson,
msg,
replyTo,
preferredUsername,
self.gatherLinkPreviews(msg)
linkPreviews
)
proc sendChatMessage*(self: Controller,
msg: string,
replyTo: string,
contentType: int,
preferredUsername: string = "") =
preferredUsername: string = "",
linkPreviews: seq[LinkPreview]) =
self.chatService.sendChatMessage(
self.chatId,
msg,
replyTo,
contentType,
preferredUsername,
self.gatherLinkPreviews(msg)
linkPreviews
)
proc requestAddressForTransaction*(self: Controller, fromAddress: string, amount: string, tokenAddress: string) =
@ -186,7 +183,4 @@ proc onUrlsUnfurled(self: Controller, args: LinkPreviewV2DataArgs) =
self.delegate.updateLinkPreviewsFromCache(urls)
proc reloadLinkPreview*(self: Controller, url: string) =
if url.len == 0:
return
self.messageService.asyncUnfurlUrls(@[url])
self.messageService.asyncUnfurlUrls(@[url])

View File

@ -18,10 +18,10 @@ method isLoaded*(self: AccessInterface): bool {.base.} =
method getModuleAsVariant*(self: AccessInterface): QVariant {.base.} =
raise newException(ValueError, "No implementation available")
method sendChatMessage*(self: AccessInterface, msg: string, replyTo: string, contentType: int) {.base.} =
method sendChatMessage*(self: AccessInterface, msg: string, replyTo: string, contentType: int, linkPreviews: seq[LinkPreview]) {.base.} =
raise newException(ValueError, "No implementation available")
method sendImages*(self: AccessInterface, imagePathsJson: string, msg: string, replyTo: string): string {.base.} =
method sendImages*(self: AccessInterface, imagePathsJson: string, msg: string, replyTo: string, linkPreviews: seq[LinkPreview]): string {.base.} =
raise newException(ValueError, "No implementation available")
method requestAddressForTransaction*(self: AccessInterface, fromAddress: string, amount: string, tokenAddress: string) {.base.} =

View File

@ -65,16 +65,17 @@ method getModuleAsVariant*(self: Module): QVariant =
proc getChatId*(self: Module): string =
return self.controller.getChatId()
method sendImages*(self: Module, imagePathsAndDataJson: string, msg: string, replyTo: string): string =
self.controller.sendImages(imagePathsAndDataJson, msg, replyTo, singletonInstance.userProfile.getPreferredName())
method sendImages*(self: Module, imagePathsAndDataJson: string, msg: string, replyTo: string, linkPreviews: seq[LinkPreview]): string =
self.controller.sendImages(imagePathsAndDataJson, msg, replyTo, singletonInstance.userProfile.getPreferredName(), linkPreviews)
method sendChatMessage*(
self: Module,
msg: string,
replyTo: string,
contentType: int) =
contentType: int,
linkPreviews: seq[LinkPreview]) =
self.controller.sendChatMessage(msg, replyTo, contentType,
singletonInstance.userProfile.getPreferredName())
singletonInstance.userProfile.getPreferredName(), linkPreviews)
method requestAddressForTransaction*(self: Module, fromAddress: string, amount: string, tokenAddress: string) =
self.controller.requestAddressForTransaction(fromAddress, amount, tokenAddress)

View File

@ -49,10 +49,10 @@ QtObject:
msg: string,
replyTo: string,
contentType: int) {.slot.} =
self.delegate.sendChatMessage(msg, replyTo, contentType)
self.delegate.sendChatMessage(msg, replyTo, contentType, self.linkPreviewModel.getUnfuledLinkPreviews())
proc sendImages*(self: View, imagePathsAndDataJson: string, msg: string, replyTo: string): string {.slot.} =
self.delegate.sendImages(imagePathsAndDataJson, msg, replyTo)
self.delegate.sendImages(imagePathsAndDataJson, msg, replyTo, self.linkPreviewModel.getUnfuledLinkPreviews())
proc acceptAddressRequest*(self: View, messageId: string , address: string) {.slot.} =
self.delegate.acceptRequestAddressForTransaction(messageId, address)
@ -211,9 +211,6 @@ QtObject:
proc clearLinkPreviewCache*(self: View) {.slot.} =
self.delegate.clearLinkPreviewCache()
proc removeLinkPreview(self: View, link: string) {.slot.} =
self.linkPreviewModel.removePreviewData(link)
proc reloadLinkPreview(self: View, link: string) {.slot.} =
self.delegate.reloadLinkPreview(link)

View File

@ -110,20 +110,8 @@ QtObject:
of ModelRole.ThumbnailDataUri:
result = newQVariant(item.linkPreview.thumbnail.dataUri)
proc urlExists(self: Model, url: string): bool =
for it in self.items:
if(it.linkPreview.url == url):
return true
return false
proc urlExists(self: seq[string], url: string): bool =
for it in self:
if(it == url):
return true
return false
proc removeItemWithIndex(self: Model, ind: int) =
if(ind == -1 or ind >= self.items.len):
if(ind < 0 or ind >= self.items.len):
return
let parentModelIndex = newQModelIndex()
@ -144,6 +132,25 @@ QtObject:
return i
return -1
proc moveRow(self: Model, fromRow: int, to: int) =
if fromRow == to:
return
if fromRow < 0 or fromRow >= self.items.len:
return
if to < 0 or to >= self.items.len:
return
let sourceIndex = newQModelIndex()
defer: sourceIndex.delete
let destIndex = newQModelIndex()
defer: destIndex.delete
let currentItem = self.items[fromRow]
self.beginMoveRows(sourceIndex, fromRow, fromRow, destIndex, to)
self.items.delete(fromRow)
self.items.insert(currentItem, to)
self.endMoveRows()
proc updateLinkPreviews*(self: Model, linkPreviews: Table[string, LinkPreview]) =
for row, item in self.items:
if not linkPreviews.hasKey(item.linkPreview.url) or item.immutable:
@ -156,42 +163,36 @@ QtObject:
proc setUrls*(self: Model, urls: seq[string]) =
var itemsToInsert: seq[Item]
var itemsToUpdate: Table[string, LinkPreview]
var indexesToRemove: seq[int]
#remove
for i in 0 ..< self.items.len:
if not urls.urlExists(self.items[i].linkPreview.url):
if not urls.anyIt(it == self.items[i].linkPreview.url):
indexesToRemove.add(i)
while indexesToRemove.len > 0:
let index = pop(indexesToRemove)
self.removeItemWithIndex(index)
self.countChanged()
# Update or insert
for url in urls:
let linkPreview = initLinkPreview(url)
if(self.urlExists(url)):
itemsToUpdate[url] = linkPreview
else:
var item = Item()
item.unfurled = false
item.immutable = false
item.linkPreview = linkPreview
itemsToInsert.add(item)
# Move or insert
for i in 0 ..< urls.len:
let index = self.findUrlIndex(urls[i])
if index >= 0:
self.moveRow(index, i)
continue
#update
if(itemsToUpdate.len > 0):
self.updateLinkPreviews(itemsToUpdate)
let linkPreview = initLinkPreview(urls[i])
var item = Item()
item.unfurled = false
item.immutable = false
item.linkPreview = linkPreview
let parentModelIndex = newQModelIndex()
defer: parentModelIndex.delete
self.beginInsertRows(parentModelIndex, i, i)
self.items.insert(item, i)
self.endInsertRows()
#insert
let parentModelIndex = newQModelIndex()
defer: parentModelIndex.delete
self.beginInsertRows(parentModelIndex, self.items.len, self.items.len + itemsToInsert.len - 1)
self.items = concat(self.items, itemsToInsert)
self.endInsertRows()
self.countChanged()
proc clearItems*(self: Model) =
@ -200,15 +201,20 @@ QtObject:
self.endResetModel()
self.countChanged()
proc removePreviewData*(self: Model, link: string) =
let index = self.findUrlIndex(link)
if index < 0:
proc removePreviewData*(self: Model, index: int) {.slot.} =
if index < 0 or index >= self.items.len:
return
self.items[index].linkPreview = initLinkPreview(link)
self.items[index].linkPreview = initLinkPreview(self.items[index].linkPreview.url)
self.items[index].unfurled = false
self.items[index].immutable = true
let modelIndex = self.createIndex(index, 0, nil)
defer: modelIndex.delete
self.dataChanged(modelIndex, modelIndex)
self.dataChanged(modelIndex, modelIndex)
proc getUnfuledLinkPreviews*(self: Model): seq[LinkPreview] =
result = @[]
for item in self.items:
if item.unfurled and item.linkPreview.hostName != "":
result.add(item.linkPreview)

View File

@ -1,5 +1,6 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Controls.Universal 2.15
import QtQuick.Layouts 1.15
import Qt.labs.settings 1.0
@ -9,6 +10,8 @@ import StatusQ 0.1 // https://github.com/status-im/status-desktop/issues/10218
import StatusQ.Core.Theme 0.1
import Storybook 1.0
import utils 1.0
ApplicationWindow {
id: root
@ -114,15 +117,7 @@ ApplicationWindow {
Layout.fillWidth: true
text: "Dark mode"
StatusLightTheme { id: lightTheme }
StatusDarkTheme { id: darkTheme }
Binding {
target: Theme
property: "palette"
value: darkModeCheckBox.checked ? darkTheme : lightTheme
}
onCheckedChanged: Style.changeTheme(checked ? Universal.Dark : Universal.Light, !checked)
}
HotReloaderControls {

View File

@ -5,29 +5,21 @@ import QtGraphicalEffects 1.15
import StatusQ.Core.Theme 0.1
import shared.controls.chat 1.0
SplitView {
Pane {
SplitView.fillWidth: true
SplitView.fillHeight: true
Rectangle {
id: wrapper
anchors.fill: parent
color: Theme.palette.statusChatInput.secondaryBackgroundColor
Page {
Rectangle {
id: wrapper
anchors.fill: parent
color: Theme.palette.statusChatInput.secondaryBackgroundColor
ChatInputLinksPreviewArea {
id: chatInputLinkPreviewsArea
anchors.centerIn: parent
width: parent.width
imagePreviewModel: ["https://picsum.photos/200/300?random=1", "https://picsum.photos/200/300?random=1"]
linkPreviewModel: linkPreviewListModel
onLinkRemoved: linkPreviewListModel.remove(index)
}
ChatInputLinksPreviewArea {
id: chatInputLinkPreviewsArea
anchors.centerIn: parent
width: parent.width
imagePreviewModel: ["https://picsum.photos/200/300?random=1", "https://picsum.photos/200/300?random=1"]
linkPreviewModel: linkPreviewListModel
onLinkRemoved: linkPreviewListModel.remove(index)
}
}
Pane {
SplitView.fillWidth: true
SplitView.fillHeight: true
}
ListModel {
id: linkPreviewListModel

View File

@ -94,6 +94,43 @@ SplitView {
sourceComponent: StatusChatInput {
id: chatInput
property var globalUtils: globalUtilsMock.globalUtils
property string unformattedText: textInput.getText(0, textInput.length)
onUnformattedTextChanged: {
textEditConnection.enabled = false
var words = unformattedText.split(" ")
fakeLinksModel.clear()
words.forEach(function(word){
if(Utils.isURL(word)) {
fakeLinksModel.append({
url: encodeURI(word),
unfurled: Math.floor(Math.random() * 2),
immutable: false,
hostname: Math.floor(Math.random() * 2) ? "youtube.com" : "",
title: "PSY - GANGNAM STYLE(강남스타일) M/V",
description: "This is the description of the link",
linkType: Math.floor(Math.random() * 3),
thumbnailWidth: 480,
thumbnailHeight: 360,
thumbnailUrl: "https://picsum.photos/480/360?random=1",
thumbnailDataUri: ""
})
}
})
textEditConnection.enabled = true
}
Connections {
id: textEditConnection
target: chatInput.textInput
function onTextChanged() {
if(unformattedText !== chatInput.textInput.getText(0, chatInput.textInput.length))
unformattedText = chatInput.textInput.getText(0, chatInput.textInput.length)
}
}
enabled: enabledCheckBox.checked
linkPreviewModel: fakeLinksModel
usersStore: QtObject {
@ -178,25 +215,12 @@ SplitView {
onCurrentIndexChanged: {
if(!chatInputLoader.item)
return
chatInputLoader.item.textInput.clear()
fakeLinksModel.clear()
let urls = ""
for (let i = 0; i < linksNb.currentIndex ; i++) {
const url = "https://www.youtube.com/watch?v=9bZkp7q19f0" + Math.floor(Math.random() * 100)
chatInputLoader.item.textInput.append(url + "\n")
fakeLinksModel.append({
url: url,
unfurled: Math.floor(Math.random() * 2),
immutable: false,
hostname: Math.floor(Math.random() * 2) ? "youtube.com" : "",
title: "PSY - GANGNAM STYLE(강남스타일) M/V",
description: "This is the description of the link",
linkType: Math.floor(Math.random() * 3),
thumbnailWidth: 480,
thumbnailHeight: 360,
thumbnailUrl: "https://picsum.photos/480/360?random=1",
thumbnailDataUri: ""
})
urls += "https://www.youtube.com/watch?v=9bZkp7q19f0" + Math.floor(Math.random() * 100) + " "
}
chatInputLoader.item.textInput.text = urls
}
}
}
@ -220,3 +244,5 @@ SplitView {
}
// category: Components
// https://www.figma.com/file/Mr3rqxxgKJ2zMQ06UAKiWL/💬-ChatDesktop?type=design&node-id=23155-66084&mode=design&t=VWBVK4DOUxr1BmTp-0

View File

@ -18,6 +18,7 @@ ApplicationWindow {
}
loader.setSource("InspectionPanel.qml", properties)
pinchHandler.target.scale = 1
}
Connections {
@ -54,8 +55,8 @@ ApplicationWindow {
clip: true
contentWidth: content.width
contentHeight: content.height
contentWidth: content.width * content.scale
contentHeight: content.height * content.scale
Item {
id: content
@ -71,11 +72,16 @@ ApplicationWindow {
Loader {
id: loader
anchors.centerIn: parent
}
}
}
PinchHandler {
id: pinchHandler
target: content
maximumRotation: 0
minimumRotation: 0
}
Pane {
Layout.fillWidth: true

View File

@ -133,15 +133,14 @@ Item {
function restoreInputState() {
if (!d.activeChatContentModule) {
chatInput.textInput.text = ""
chatInput.setText("")
chatInput.resetReplyArea()
chatInput.resetImageArea()
return
}
// Restore message text
chatInput.textInput.text = d.activeChatContentModule.inputAreaModule.preservedProperties.text
chatInput.textInput.cursorPosition = chatInput.textInput.length
chatInput.setText(d.activeChatContentModule.inputAreaModule.preservedProperties.text)
d.restoreInputReply()
d.restoreInputAttachments()
@ -305,7 +304,7 @@ Item {
{
Global.playSendMessageSound()
chatInput.textInput.clear();
chatInput.setText("")
chatInput.textInput.textFormat = TextEdit.PlainText;
chatInput.textInput.textFormat = TextEdit.RichText;
}
@ -314,8 +313,7 @@ Item {
onKeyUpPress: {
d.activeMessagesStore.setEditModeOnLastMessage(root.rootStore.userProfileInst.pubKey)
}
onLinkPreviewRemoved: (link) => d.activeChatContentModule.inputAreaModule.removeLinkPreview(link)
onLinkPreviewReloaded: (link) => d.activeChatContentModule.inputAreaModule.reloadLinkPreview(link)
}

View File

@ -38,6 +38,13 @@ Control {
opacity: 0
WheelHandler {
target: flickable
property: "contentX"
acceptedDevices: PointerDevice.Mouse
onActiveChanged: if(!active) flickable.returnToBounds()
}
Flickable {
id: flickable
@ -87,17 +94,17 @@ Control {
titleStr: title
domain: hostname //TODO: use domain when available
favIconUrl: thumbnailImageUrl //TODO: use favicon when available
favIconUrl: "" //TODO: use favicon when available
communityName: "" //TODO: add community info when available
channelName: "" //TODO: add community info when available
thumbnailImageUrl: thumbnailDataUri.length > 0 ? thumbnailDataUri : thumbnailUrl
thumbnailImageUrl: thumbnailUrl.length > 0 ? thumbnailUrl : thumbnailDataUri
type: linkType === 0 ? LinkPreviewMiniCard.Type.Link : LinkPreviewMiniCard.Type.Image
previewState: unfurled && hostname != "" ? LinkPreviewMiniCard.State.Loaded :
unfurled && hostname === "" ? LinkPreviewMiniCard.State.LoadingFailed :
!unfurled ? LinkPreviewMiniCard.State.Loading : LinkPreviewMiniCard.State.Invalid
onClose: root.linkRemoved(url)
onClose: root.linkPreviewModel.removePreviewData(d.filteredModel.mapToSource(index))
onRetry: root.linkReload(url)
onClicked: root.linkClicked(url)
onContainsMouseChanged: {
@ -117,20 +124,20 @@ Control {
}
}
}
LinearGradient {
Rectangle {
id: horizontalClipMask
anchors.fill: opacityMaskWrapper
visible: false
start: Qt.point(0 , 0)
end: Qt.point(horizontalClipMask.width, 0)
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: root.horizontalPadding / horizontalClipMask.width; color: "white" }
GradientStop { position: 1 - root.horizontalPadding / horizontalClipMask.width; color: "white" }
GradientStop { position: 1; color: "transparent" }
}
}
OpacityMask {
anchors.fill: opacityMaskWrapper
source: opacityMaskWrapper

View File

@ -25,7 +25,6 @@ CalloutCard {
signal clicked(var mouse)
borderWidth: 1
padding: borderWidth
implicitHeight: 290
implicitWidth: 305

View File

@ -47,7 +47,6 @@ CalloutCard {
implicitHeight: 64
verticalPadding: 15
horizontalPadding: 12
visible: true
borderColor: Theme.palette.directColor7
backgroundColor: root.containsMouse ? Theme.palette.directColor7 : Theme.palette.baseColor4
@ -117,12 +116,23 @@ CalloutCard {
name: "loadedUser"
when: root.previewState === LinkPreviewMiniCard.State.Loaded && root.type === LinkPreviewMiniCard.Type.User
extend: "loaded"
PropertyChanges { target: favIcon; visible: true; name: root.titleStr; asset.isLetterIdenticon: true; asset.charactersLen: 2; asset.color: Theme.palette.miscColor9; }
PropertyChanges {
target: favIcon
visible: true
name: root.titleStr
asset.isLetterIdenticon: true
asset.charactersLen: 2
asset.color: Theme.palette.miscColor9
}
}
]
contentItem: Item {
implicitHeight: layout.implicitHeight
implicitWidth: layout.implicitWidth
RowLayout {
id: layout
anchors.fill: parent
spacing: 0
LoadingAnimation {

View File

@ -112,6 +112,17 @@ Rectangle {
textInput.cursorPosition = textInput.length
}
function setText(text) {
const textInputEnabled = textInput.enabled
if(textInputEnabled) {
textInput.enabled = false
}
textInput.clear()
textInput.append(text)
textInput.enabled = textInputEnabled
}
implicitWidth: layout.implicitWidth + layout.anchors.leftMargin + layout.anchors.rightMargin
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
@ -1314,8 +1325,6 @@ Rectangle {
const mention = d.getMentionAtPosition(cursorPosition - 1)
if(mention) {
select(mention.leftIndex, mention.rightIndex)
} else {
Global.openLink(link)
}
}
@ -1348,6 +1357,7 @@ Rectangle {
textEdit: messageInputField
urlModel: control.linkPreviewModel
highlightUrl: linkPreviewArea.hoveredUrl
enabled: messageInputField.enabled && messageInputField.textFormat == TextEdit.RichText
}
Shortcut {

View File

@ -3,10 +3,15 @@ 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
@ -24,6 +29,16 @@ QtObject {
*/
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
*/
@ -31,11 +46,12 @@ QtObject {
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 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 {
@ -52,8 +68,6 @@ QtObject {
// 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)
// The text is the same as the input text
readonly property string text: root.textEdit.text
// Behavior
@ -61,9 +75,12 @@ QtObject {
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
onTextChanged: {
replaceAll("(<br/>|<br />| )+(</span></a>)", "$2$1") // Move spaces outside of the hyperlink tag
replaceAll(hyperlinksFormatter.selectLinkWithoutAnchors.arg(hyperlinkDelegate.escapedURLforRegex), hyperlinkDelegate.hyperlinkToInsert)
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)
@ -74,18 +91,26 @@ QtObject {
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) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched 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) {
return string.replace(/\$/g, '$$$$');
let result = decodeURI(string) // decode the url to be able to use it in the regex
return result.replace(/\$/g, '$$$$');
}
}
}

View File

@ -105,7 +105,7 @@ Flow {
id: unfurledLink
leftTail: !root.isCurrentUser
bannerImageSource: thumbnailUrl
bannerImageSource: thumbnailUrl.length > 0 ? thumbnailUrl : thumbnailDataUri
title: parent.title
description: parent.description
footer: hostname

View File

@ -0,0 +1,158 @@
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)
print("Reducing undo stack to " + newStackSize + " items")
for(var i = 1; i <= newStackSize; i++) {
print("Removing " + Math.ceil(root.maxStackSize / newStackSize) + " items from index " + i)
d.undoStack.splice(i, Math.ceil(root.maxStackSize / newStackSize))
}
}
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 previousFormattedTextCopy = d.previousFormattedText
d.undoStack.push({
undo: function() {
d.aboutToChangeText = true
root.textEdit.text = previousFormattedTextCopy
root.textEdit.cursorPosition = root.textEdit.length
d.aboutToChangeText = false
},
redo: function() {
d.aboutToChangeText = true
root.textEdit.text = newFormattedText
root.textEdit.cursorPosition = root.textEdit.length
d.aboutToChangeText = false
}
})
d.reduceUndoStack()
d.previousText = unformattedText
d.previousFormattedText = newFormattedText
}
}
}
}
}

View File

@ -1,5 +1,6 @@
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