From 26a4d76fc29ab173f2cc367480134a7e143b8132 Mon Sep 17 00:00:00 2001 From: miruka Date: Sun, 19 Jul 2020 03:02:14 -0400 Subject: [PATCH] Implement basic image viewer popup Current features: - Show scaled up thumbnail while the full image is loading - Click to alternate between scaling mode (or reset zoom if not 1x) - Click outside of image to close - Double click to toggle fullscreen - Middle click to open externally (also for thumbnail in timeline) - Right click anywhere to close - Ctrl+wheel to zoom - Click-drag to pan when image larger than window --- TODO.md | 8 + src/gui/Base/HFlickable.qml | 1 + src/gui/Base/HMxcImage.qml | 5 +- src/gui/Pages/Chat/Timeline/EventImage.qml | 43 ++++- src/gui/Popups/ImageViewerPopup.qml | 186 +++++++++++++++++++++ 5 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 src/gui/Popups/ImageViewerPopup.qml diff --git a/TODO.md b/TODO.md index 9ff22c4a..c6afad2d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,14 @@ # TODO +- Image viewer: + - open externally in context menu in timeline thumbnail + - fix: gif always closes? + - hflickable support kinetic scrolling disabler and speed/decel settings + - buttons + - keyboard controls + - Avatar tooltip can get displayed in front of presence menu +- Use loading cursorShape - global presence control diff --git a/src/gui/Base/HFlickable.qml b/src/gui/Base/HFlickable.qml index 42f002d5..ded1c2f2 100644 --- a/src/gui/Base/HFlickable.qml +++ b/src/gui/Base/HFlickable.qml @@ -9,5 +9,6 @@ Flickable { ScrollBar.vertical: HScrollBar { visible: parent.interactive + z: 999 } } diff --git a/src/gui/Base/HMxcImage.qml b/src/gui/Base/HMxcImage.qml index b1d9dee4..b38b96f9 100644 --- a/src/gui/Base/HMxcImage.qml +++ b/src/gui/Base/HMxcImage.qml @@ -12,15 +12,16 @@ HImage { property bool thumbnail: true property var cryptDict: ({}) - property bool show: false property string cachedPath: "" + property bool canUpdate: true + property bool show: ! canUpdate property Future getFuture: null readonly property bool isMxc: mxc.startsWith("mxc://") function update() { - if (! py) return // component was destroyed + if (! py || ! canUpdate) return // component was destroyed const w = sourceSize.width || width const h = sourceSize.height || height diff --git a/src/gui/Pages/Chat/Timeline/EventImage.qml b/src/gui/Pages/Chat/Timeline/EventImage.qml index d69d56b3..155a296e 100644 --- a/src/gui/Pages/Chat/Timeline/EventImage.qml +++ b/src/gui/Pages/Chat/Timeline/EventImage.qml @@ -62,6 +62,38 @@ HMxcImage { callback(toOpen) } + function openUrlExternally() { + getOpenUrl(Qt.openUrlExternally) + } + + function openImageViewer() { + utils.makePopup( + "Popups/ImageViewerPopup.qml", + { + thumbnailTitle: loader.thumbnailTitle, + thumbnailMxc: loader.thumbnailMxc, + thumbnailPath: image.cachedPath, + thumbnailCryptDict: + JSON.parse(loader.singleMediaInfo.thumbnail_crypt_dict), + + fullTitle: loader.title, + // The thumbnail/cached path will be the full GIF + fullMxc: animated ? "" : loader.mediaUrl, + fullCryptDict: + JSON.parse(loader.singleMediaInfo.media_crypt_dict), + + overallSize: Qt.size( + loader.singleMediaInfo.media_width || + loader.singleMediaInfo.thumbnail_width, + + loader.singleMediaInfo.media_height || + loader.singleMediaInfo.thumbnail_height, + ) + }, + obj => { obj.openExternallyRequested.connect(openUrlExternally) }, + ) + } + width: fitSize.width height: fitSize.height @@ -81,14 +113,23 @@ HMxcImage { ) TapHandler { + acceptedButtons: Qt.LeftButton acceptedModifiers: Qt.NoModifier onTapped: eventList.selectedCount ? - eventDelegate.toggleChecked() : getOpenUrl(Qt.openUrlExternally) + eventDelegate.toggleChecked() : + openImageViewer() gesturePolicy: TapHandler.ReleaseWithinBounds } + TapHandler { + acceptedButtons: Qt.MiddleButton + acceptedModifiers: Qt.NoModifier + onTapped: getOpenUrl(Qt.openUrlExternally) + gesturePolicy: TapHandler.ReleaseWithinBounds + } + TapHandler { acceptedModifiers: Qt.ShiftModifier onTapped: diff --git a/src/gui/Popups/ImageViewerPopup.qml b/src/gui/Popups/ImageViewerPopup.qml new file mode 100644 index 00000000..22c267b8 --- /dev/null +++ b/src/gui/Popups/ImageViewerPopup.qml @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Window 2.12 +import "../Base" + +HPopup { + id: popup + + property alias thumbnailTitle: thumbnail.title + property alias thumbnailMxc: thumbnail.mxc + property alias thumbnailPath: thumbnail.cachedPath + property alias thumbnailCryptDict: thumbnail.cryptDict + property alias fullTitle: full.title + property alias fullMxc: full.mxc + property alias fullCryptDict: full.cryptDict + property size overallSize + + property bool alternateScaling: false + property bool activedFullScreen: false + + readonly property bool imageLargerThanWindow: + overallSize.width > window.width || overallSize.height > window.height + + readonly property bool imageEqualToWindow: + overallSize.width == window.width && + overallSize.height == window.height + + readonly property int paintedWidth: + full.status === Image.Ready? + full.paintedWidth : + thumbnail.paintedWidth + + readonly property int paintedHeight: + full.status === Image.Ready ? + full.paintedHeight : + thumbnail.paintedHeight + + signal openExternallyRequested() + + + margins: 0 + background: null + + onAboutToHide: if (activedFullScreen) window.showNormal() + + HFlickable { + id: flickable + pressDelay: 30 + implicitWidth: window.width + implicitHeight: window.height + contentWidth: + Math.max(window.width, popup.paintedWidth * thumbnail.scale) + contentHeight: + Math.max(window.height, popup.paintedHeight * thumbnail.scale) + + ScrollBar.vertical: null + + TapHandler { + onTapped: popup.close() + gesturePolicy: TapHandler.ReleaseWithinBounds + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: { + if (wheel.modifiers !== Qt.ControlModifier) { + wheel.accepted = false + return + } + + wheel.accepted = true + const add = wheel.angleDelta.y / 120 / 5 + thumbnail.scale = Math.max( + 0.1, Math.min(10, thumbnail.scale + add), + ) + } + } + + HMxcImage { + id: thumbnail + anchors.centerIn: parent + width: + popup.alternateScaling && popup.imageLargerThanWindow ? + popup.overallSize.width : + + popup.alternateScaling ? + window.width : + + Math.min(window.width, popup.overallSize.width) + + height: + popup.alternateScaling && popup.imageLargerThanWindow ? + popup.overallSize.height : + + popup.alternateScaling ? + window.height : + + Math.min(window.height, popup.overallSize.height) + + fillMode: HMxcImage.PreserveAspectFit + // Use only the cachedPath, don't waste time refetching thumb + canUpdate: false + + Behavior on width { + HNumberAnimation { overshoot: popup.alternateScaling? 2 : 3 } + } + + Behavior on height { + HNumberAnimation { overshoot: popup.alternateScaling? 2 : 3 } + } + + Binding on showProgressBar { + value: false + when: ! thumbnail.show + } + + HNumberAnimation { + id: resetScaleAnimation + target: thumbnail + property: "scale" + from: thumbnail.scale + to: 1 + overshoot: 2 + } + + Timer { + // Timer to not disappear before full image is done rendering + interval: 1000 + running: full.status === HMxcImage.Ready + onTriggered: thumbnail.show = false + } + + HMxcImage { + id: full + anchors.fill: parent + thumbnail: false + fillMode: parent.fillMode + // Image never loads at 0 opacity or with visible: false + opacity: status === HMxcImage.Ready ? 1 : 0.01 + + Behavior on opacity { HNumberAnimation {} } + } + + Item { + anchors.centerIn: parent + width: popup.paintedWidth + height: popup.paintedHeight + + TapHandler { + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { + thumbnail.scale === 1 ? + popup.alternateScaling = ! popup.alternateScaling : + resetScaleAnimation.start() + } + onDoubleTapped: { + if (window.visibility === Window.FullScreen) { + window.showNormal() + popup.activedFullScreen = false + popup.alternateScaling = false + } else { + window.showFullScreen() + popup.activedFullScreen = true + popup.alternateScaling = true + } + } + } + + TapHandler { + acceptedButtons: Qt.MiddleButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: popup.openExternallyRequested() + } + + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: popup.close() + } + } + } + } +}