logarithm zoom

import QtQuick
import QtQuick.Controls


Flickable {
  id: root

  function fitToWidth() {
    root.setZoom(root.width / image.width);
  }

  function fitToHeight() {
    root.setZoom(root.height / image.height);
  }

  function fitToSize() {
    root.setZoom(Math.min(root.width / image.width, root.height / image.height));
  }

  function setZoom(zoom: real, originX: real, originY: real) {
    const roundToFixed = (value, digits) => {
      const factor = Math.pow(10, digits);
      return Math.round(value * factor) / factor;
    }

    // Change zoom in origin, which defaults to the center of the Flickable
    const x = isNaN(originX) ? (root.contentX + root.width / 2) : originX
    const y = isNaN(originY) ? (root.contentY + root.height / 2) : originY

    const clampedZoom = Math.min(root.maxZoom, Math.max(root.minZoom, zoom));
    const roundedZoom = roundToFixed(clampedZoom, root.digits);
    const normalizedFactor = (roundedZoom - root.zoom) / root.zoom;

    // Update Flickable's state
    root.zoom = roundedZoom;
    root.contentX += x * normalizedFactor;
    root.contentY += y * normalizedFactor;
  }

  property alias source: image.source
  property alias zoom: image.scale
  readonly property real minZoom: 0.25
  readonly property real maxZoom: 16.0
  readonly property real zoomStepSize: 0.5
  readonly property int digits: 2 // Should match or be larger than number of digits in `zoomStepSize`

  clip: true
  contentWidth: image.width * image.scale
  contentHeight: image.height * image.scale
  boundsMovement: Flickable.StopAtBounds
  ScrollBar.horizontal: ScrollBar {}
  ScrollBar.vertical: ScrollBar {}

  Image {
    id: image

    // Use the available width to fit image. Due to the fillMode, the image
    // will be scaled accordingly. The initial scale is unaffected and will
    // remain at 1.
    width: root.width - root.leftMargin - root.rightMargin
    fillMode: Image.PreserveAspectFit
    // `transformOrigin` needs to be mapped to root.contentX = 0, root.contentY = 0
    transformOrigin: Item.TopLeft
    horizontalAlignment: Image.AlignLeft

    // Settings optimized for scaling
    antialiasing: true
    asynchronous: true
    cache: false
    mipmap: true
    smooth: true

    // Remember, x & y have two bindings: Their original value and the
    // conditional bindings that are active when the image's size becomes
    // smaller than the viewport, on zoom out.
    x: 0
    y: 0

    // Two bindings to keep the image centered on zoom out.
    Binding on x {
      when: root.width > root.contentWidth
      value: (root.width - root.contentWidth) / 2
    }
    Binding on y {
      when: root.height > root.contentHeight
      value: (root.height - root.contentHeight) / 2
    }
  }
  MouseArea {
    // Sometimes, direct positioning (and sizing) is the best way...
    x: image.x
    y: image.y
    width: parent.width
    height: parent.height

    acceptedButtons: Qt.NoButton

    function normalize( value : real, min : real, max : real ) : real {
      return ( value - min ) / ( max - min );
    }

    function logerp( min : real, max : real, value: real ) : real {
      return min * Math.pow( max / min, value );
    }

    property real vzoom: 0

    onWheel: (event) => {
               const stepSize = event.angleDelta.y > 0 ? root.zoomStepSize : -root.zoomStepSize;
               vzoom = Math.max( Math.min( ( vzoom + stepSize ), root.maxZoom ), 0 )
               const v = normalize( vzoom, root.minZoom, root.maxZoom )
               root.setZoom( logerp( root.minZoom, root.maxZoom, v ), event.x, event.y );

               // Consumes the mouse event before the `Flickable` can process it.
               wheel.accepted = true;
             }
  }
}