feat(StatusImageCrop): Image cropping editor control

New QML control StatusQ.Controls.StatusImageCrop allows user to crop an image for avatar/banner purpuses

Features:
- Support rectangular and elliptical crop windows
- Transparency for the wall
- Controlling the crop window in image coordinates using cropRect
- The image support is based on `Image`
- Limits the

fixes: #5401
This commit is contained in:
Stefan 2022-04-07 21:24:36 +03:00 committed by Stefan Dunca
parent 310be728a3
commit 2b71ccf50f
2 changed files with 335 additions and 0 deletions

View File

@ -0,0 +1,334 @@
import QtQuick 2.14
import QtQuick.Layouts 1.14
import QtGraphicalEffects 1.1
import StatusQ.Core.Utils 0.1
import StatusQ.Core.Theme 0.1
/*!
\qmltype StatusImageCrop
\inherits Item
\inqmlmodule StatusQ.Controls
\since StatusQ.Controls 0.1
\brief Draw a crop-window onto an image. Inherits \l{https://doc.qt.io/qt-5/qml-qtquick-item.html}{Item}.
Sizing
User resizes the control as needed. Control adapts and centers the crop
window inside the available space
Performance
The \c StatusImageCrop uses Canvas to draw the overlay in multiple steps.
Drawing with canvas is inefficient therefore keep the usage to a minimum,
as in the required space
API
The setCropRect is the only way to set the content shown in the crop window.
/note "Fit window" used here reffer to optimum zoom to fit all the content
in the view window; produces window borders if AR of the image is different
than the window. This ia opposite to "Fill window" reffers to optimum zoom
to fill all the view; parts of the content won't be visible if AR of the
image is different than the AR of window
/note The math is based on pixels so no subpixel accuracy. Didn't find it necessary for the current needs
/note windowRect aspect ratio and cropRect aspect ratio are keept in sync
Example of two controls; top WindowStyle.Rounded, bottom WindowStyle.Rectangular
\image eg-StatusImageCrop.png
Example of how to use it:
\qml
StatusImageCrop {
anchors.fill: parent
width: 100
height: 100 * aspectRatio
source: "qrc:/demoapp/data/logo-test-image.png"
windowStyle: StatusImageCrop.WindowStyle.Rounded
}
\endqml
For a list of components available see StatusQ.
*/
Item {
id: root
implicitWidth: 400
implicitHeight: 400 / aspectRatio
/*!
\qmlproperty url StatusImageCrop::source
Path to the image to be cropped
*/
/*required*/ property alias source: mainImage.source
enum WindowStyle {
Rounded,
Rectangular
}
/*!
\qmlproperty WindowStyle StatusImageCrop::windowStyle
Crop window styles
\value Rounded ellipses bounded by cropRect rectangle
\value Rectangular with rounded corners specified by radius
*/
property int windowStyle: StatusImageCrop.WindowStyle.Rectangular
/*!
\qmlproperty int StatusImageCrop::radius
Valid only when windowStyle is Rounded
*/
property int radius: 8
/*!
\qmlproperty color StatusImageCrop::wallColor
Color used outside the crop-rect. Tells user which part won't be available after cropping
*/
property color wallColor: Theme.palette.black
/*!
\qmlproperty real StatusImageCrop::wallTransparency
Transparency outside the crop-rect 0.0 - 1.0 (inclusive). Tells user which part won't
be available after cropping
*/
property real wallTransparency: 0.7
/*!
\qmlproperty real StatusImageCrop::maxZoomScale
Don't allow to zoom in more than maxZoomScale factor
*/
property real maxZoomScale: 10
/*!
\qmlproperty rect StatusImageCrop::cropRect
The content shown in the crop window. Values are expected to be in image coordinates.
The crop-window aspect-ratio is adjusted to match the cropRect value.
The default value shows an 1.0 AR to Fit Window
e.g. to set the crop window to show and match image's AR use Qt.rect(0, 0, sourceSize.width, sourceSize.height)
*/
readonly property rect cropRect: d.cropRect
/*!
\qmlproperty rect StatusImageCrop::cropRect
The position and zis of the crop rectangle in item's space; without extra spacing
*/
readonly property alias cropWindow: d.cropWindow
/*!
\qmlproperty real StatusImageCrop::aspectRatio
*/
readonly property real aspectRatio: d.cropRect.height !== 0 ? d.cropRect.width/d.cropRect.height : 0
/*!
\qmlproperty real StatusImageCrop::zoomScale
*/
readonly property real zoomScale: d.currentZoom(sourceSize, Qt.size(d.cropRect.width, d.cropRect.height))
/*!
\qmlproperty size StatusImageCrop::sourceSize
Image size if one is set. StatusImageCrop::cropRect is relative to the rect with top-corner
(0,0) and StatusImageCrop::sourceSize
\sa StatusImageCrop::source
*/
readonly property size sourceSize: mainImage.sourceSize
/*!
\qmlproperty real StatusImageCrop::scrToImgScale
Screen crop window to \a cropRect ratio. Can be used to translate from
screen coordinates into image coordinates e.g. to adjust \a cropRect accordingly
\note windowRect aspect-ratio and d.cropRect aspect-ratio are the same
\sa StatusImageCrop::cropRect
*/
readonly property real scrToImgScale: windowRect.wW/d.cropRect.width
/*!
\qmlmethod StatusImageCrop::setCropRect(rect)
The only way to set StatusImageCrop::cropRect from outside
The new rect will be adjusted to account for the zoom [1, StatusImageCrop::maxZoomScale]
\note If the new rect has a diferent area the crop window will adjust to the new AR
*/
function setCropRect(newRect /*rect*/) {
if(newRect.width === 0 || newRect.height === 0)
return
let n = newRect
const s = root.sourceSize
let nZoom = d.currentZoom(s, Qt.size(n.width, n.height))
if(nZoom > root.maxZoomScale) {
nZoom = root.maxZoomScale
n = root.getZoomRect(nZoom)
}
else if(nZoom < 1) {
nZoom = 1
n = root.getZoomRect(nZoom)
}
// Limit panning
if((n.width/n.height) < (s.width/s.height)) {
// Crop window narrower than source
if(n.x < 0)
n.x = 0
const upBoundY = n.height > s.height ? s.height - n.height : 0
if(n.y < upBoundY)
n.y = upBoundY
if((n.x + n.width) > s.width)
n.x = s.width - n.width
const loBoundY = n.height > s.height ? 0 : s.height - n.height
if(n.y > loBoundY)
n.y = loBoundY
}
else {
// Crop window wider than source
const leftBoundX = n.width > s.width ? s.width - n.width : 0
if(n.x < leftBoundX)
n.x = leftBoundX
if(n.y < 0)
n.y = 0
const rightBoundY = n.width > s.width ? 0 : s.width - n.width
if(n.x > rightBoundY)
n.x = rightBoundY
if((n.y + n.height) > s.height)
n.y = s.height - n.height
}
d.cropRect = n
}
function getZoomRect(scale /*real*/) {
const oldCenter = root.rectCenter(root.cropRect)
const inflatedRect = root.inflateRectBy(root.minimumCropRect(), 1/scale)
return root.recenterRect(inflatedRect, oldCenter);
}
/*!
\qmlmethod StatusImageCrop::inflateRectBy(rect target, real scale)
Inflates the curren \a target rectangle with the \a scale while keeping the center fixed
*/
function inflateRectBy(target /*rect*/, scale /*real*/) {
const inflatedWidth = target.width * scale
const inflatedHeight = target.height * scale
return Qt.rect(target.x - (inflatedWidth - target.width)/2, target.y - (inflatedHeight - target.height)/2, inflatedWidth, inflatedHeight)
}
/*!
\qmlmethod StatusImageCrop::rectCenter(rect target)
Returns the center point of the \a target rectangle as a Qt.point
*/
function rectCenter(target /*rect*/) /*Qt.point*/ {
return Qt.point(target.x + target.width/2, target.y + target.height/2)
}
/*!
\qmlmethod StatusImageCrop::recenterRect(rect target)
Move the \a target rectangle's center to a /a newCenter
*/
function recenterRect(target /*rect*/, newCenter/*point*/) {
return Qt.rect(newCenter.x - target.width/2 , newCenter.y - target.height/2, target.width, target.height)
}
//> cropRect for minimum zoom: 1.0
function minimumCropRect() {
const sourceAR = root.sourceSize.width/root.sourceSize.height
const widthBound = sourceAR > root.aspectRatio
const minCropSize = widthBound ? Qt.size(root.sourceSize.width, root.sourceSize.width/root.aspectRatio)
: Qt.size(root.sourceSize.height * root.aspectRatio, root.sourceSize.height)
let res = Qt.rect(widthBound ? 0 : -(root.sourceSize.width - minCropSize.width)/2, // x
widthBound ? -(root.sourceSize.height - minCropSize.height)/2 : 0, // y
minCropSize.width, minCropSize.height)
return res
}
QtObject {
id: d
property rect cropRect: fillContentInSquaredWindow(sourceSize)
onCropRectChanged: windowRect.requestPaint()
property rect cropWindow
// Probably called from render thread, run async
signal updateCropWindow(rect newRect)
onUpdateCropWindow: cropWindow = newRect
function fillContentInSquaredWindow(c /*size*/) {
if(c.width > c.height) {
const border = (c.width - c.height)/2
return Qt.rect(border, 0, c.height, c.height)
}
else {
const border = (c.height - c.width)/2
return Qt.rect(0, border, c.width, c.width)
}
}
//> 1.0 is the content represented by w fully inscribed in c
function currentZoom(c /*size*/, w /*size*/) {
const wScale = c.width/w.width
const hScale = c.height/w.height
return Math.max(wScale, hScale)
}
}
onWindowStyleChanged: windowRect.requestPaint()
onSourceSizeChanged: d.cropRect = d.fillContentInSquaredWindow(sourceSize)
Canvas {
id: windowRect
anchors.fill: parent
layer.enabled: true
property bool widthFit: (root.width / root.aspectRatio) <= root.height
// Window width
property real wW: widthFit ? root.width : root.height * root.aspectRatio
// Window height
property real wH: widthFit ? root.width / root.aspectRatio : root.height
onPaint: {
var ctx = getContext("2d")
ctx.save()
// TODO: observed drawing artefacts at the edge. Draw one pixel more for now until root cause is found
ctx.clearRect(0, 0, width + 1, height + 1)
// Fill all with wallColor in order to clip the window from it
ctx.fillStyle = Qt.rgba(root.wallColor.r, root.wallColor.g, root.wallColor.b, root.wallTransparency)
ctx.fillRect(0, 0, width + 1, height + 1)
// Cut opaque new pixels from background
ctx.globalCompositeOperation = "source-out"
// Draw the window
ctx.beginPath()
const cW = Qt.rect((width - wW)/2, (height - wH)/2, wW, wH)
if(root.windowStyle === StatusImageCrop.WindowStyle.Rounded)
ctx.ellipse(cW.x, cW.y, cW.width, cW.height)
else if(root.windowStyle === StatusImageCrop.WindowStyle.Rectangular)
ctx.roundedRect(cW.x, cW.y, cW.width, cW.height, root.radius, root.radius)
ctx.fill()
ctx.restore()
d.updateCropWindow(cW)
}
}
Image {
id: mainImage
fillMode: Image.PreserveAspectFit
z: windowRect.z - 1
// Transform to keep the center of the image window in x and y coordinates
transform: [
Translate {
readonly property real s: mainImage.scale
x: -(d.cropRect.x + d.cropRect.width/2)
y: -(d.cropRect.y + d.cropRect.height/2)
},
Scale {
xScale: root.scrToImgScale
yScale: root.scrToImgScale
}
]
// Align window center to window rect
x: windowRect.x + windowRect.width/2
y: windowRect.y + windowRect.height/2
}
}

View File

@ -37,3 +37,4 @@ StatusWalletColorButton 0.1 StatusWalletColorButton.qml
StatusWalletColorSelect 0.1 StatusWalletColorSelect.qml StatusWalletColorSelect 0.1 StatusWalletColorSelect.qml
StatusColorSelectorGrid 0.1 StatusColorSelectorGrid.qml StatusColorSelectorGrid 0.1 StatusColorSelectorGrid.qml
StatusSeedPhraseInput 0.1 StatusSeedPhraseInput.qml StatusSeedPhraseInput 0.1 StatusSeedPhraseInput.qml
StatusImageCrop 0.1 StatusImageCrop.qml