feat: Generate link previews in StatusChatInput - Small updates + Add basic zoom to Storybook InspectionWindow
This commit is contained in:
parent
d32af78bc0
commit
422bb2c064
|
@ -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])
|
||||
|
|
|
@ -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.} =
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/💬-Chat⎜Desktop?type=design&node-id=23155-66084&mode=design&t=VWBVK4DOUxr1BmTp-0
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -25,7 +25,6 @@ CalloutCard {
|
|||
signal clicked(var mouse)
|
||||
|
||||
borderWidth: 1
|
||||
padding: borderWidth
|
||||
implicitHeight: 290
|
||||
implicitWidth: 305
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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, '&')
|
||||
result = result.replace(/</g, '<')
|
||||
result = result.replace(/>/g, '>')
|
||||
result = result.replace(/\"/g, '"')
|
||||
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, '$$$$');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue