About a week ago, I was trying to create a scalable TextureRect (as canvas) using the mouse wheel towards the cursor position, like those in Photoshop, Krita, etc.
I was able to set the scaling and its position in relation to the canvas, and as for the scroll bars, I was able to adjust the size, but until now There is no proper way to adjust the position in proportion to the position of the canvas
the setup of scene tree on GitHub

extends Control

@onready var canvas: TextureRect = $Canvas
@onready var h_scroll_bar: HScrollBar = $HScrollBar
@onready var v_scroll_bar: VScrollBar = $VScrollBar

var min_zoom = 1.0
var max_zoom = 15.0
var zoom_step = 0.1
var zoom = 1.0
var original_canvas_width: int
var original_canvas_height: int

func _ready():
    original_canvas_width = canvas.size.x
    original_canvas_height = canvas.size.y
    # Initialize the scrollbars when the scene starts
    update_scrollbars()

func _input(event):
    # Handle zooming with mouse wheel
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_WHEEL_UP:
            zoom_canvas(event.global_position, zoom_step)
        elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
            zoom_canvas(event.global_position, -zoom_step)

func zoom_canvas(mouse_position: Vector2, zoom_delta: float):
    var new_zoom = clamp(zoom + zoom_delta, min_zoom, max_zoom)
    if new_zoom == zoom:
        return

    var scale_factor = new_zoom / zoom
    zoom = new_zoom

    # Calculate the new scale
    var old_size = canvas.size
    var new_size = old_size * scale_factor

    # Adjust the position so that zooming centers around the mouse
    var offset = mouse_position - canvas.position
    var zoom_offset = offset * (1 - scale_factor)
    canvas.size = new_size
    canvas.position += zoom_offset

    # Update scrollbars
    update_scrollbars()



func update_scrollbars():
    var canvas_size_x_ratio: float = (original_canvas_width/ size.x)
    var canvas_size_y_ratio: float = (original_canvas_height/ size.y)
    h_scroll_bar.page = ( canvas_size_x_ratio / zoom )  * h_scroll_bar.max_value
    v_scroll_bar.page = ( canvas_size_y_ratio / zoom )  * v_scroll_bar.max_value
    var x = 0.5
    var y = 0.5
    h_scroll_bar.value = (h_scroll_bar.max_value-h_scroll_bar.page) - (x * (h_scroll_bar.max_value-h_scroll_bar.page))
    v_scroll_bar.value = (v_scroll_bar.max_value-v_scroll_bar.page) - (y * (v_scroll_bar.max_value-v_scroll_bar.page))extends Control

@onready var canvas: TextureRect = $Canvas
@onready var h_scroll_bar: HScrollBar = $HScrollBar
@onready var v_scroll_bar: VScrollBar = $VScrollBar

var min_zoom = 1.0
var max_zoom = 15.0
var zoom_step = 0.1
var zoom = 1.0
var original_canvas_width: int
var original_canvas_height: int

func _ready():
    original_canvas_width = canvas.size.x
    original_canvas_height = canvas.size.y
    # Initialize the scrollbars when the scene starts
    update_scrollbars()

func _input(event):
    # Handle zooming with mouse wheel
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_WHEEL_UP:
            zoom_canvas(event.global_position, zoom_step)
        elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
            zoom_canvas(event.global_position, -zoom_step)

func zoom_canvas(mouse_position: Vector2, zoom_delta: float):
    var new_zoom = clamp(zoom + zoom_delta, min_zoom, max_zoom)
    if new_zoom == zoom:
        return

    var scale_factor = new_zoom / zoom
    zoom = new_zoom

    # Calculate the new scale
    var old_size = canvas.size
    var new_size = old_size * scale_factor

    # Adjust the position so that zooming centers around the mouse
    var offset = mouse_position - canvas.position
    var zoom_offset = offset * (1 - scale_factor)
    canvas.size = new_size
    canvas.position += zoom_offset

    # Update scrollbars
    update_scrollbars()



func update_scrollbars():
    var canvas_size_x_ratio: float = (original_canvas_width/ size.x)
    var canvas_size_y_ratio: float = (original_canvas_height/ size.y)
    h_scroll_bar.page = ( canvas_size_x_ratio / zoom )  * h_scroll_bar.max_value
    v_scroll_bar.page = ( canvas_size_y_ratio / zoom )  * v_scroll_bar.max_value
    var x = 0.5
    var y = 0.5
    h_scroll_bar.value = (h_scroll_bar.max_value-h_scroll_bar.page) - (x * (h_scroll_bar.max_value-h_scroll_bar.page))
    v_scroll_bar.value = (v_scroll_bar.max_value-v_scroll_bar.page) - (y * (v_scroll_bar.max_value-v_scroll_bar.page))

The equations for the _scroll_bar.value seem correct to me, but it is necessary to find the appropriate relative value for both x and y so that the positions of the scroll bars are proportional to the scaling position.

Update: I have added additional elements to the code:

extends Control

@onready var canvas: TextureRect = $Canvas
@onready var h_scroll_bar: HScrollBar = $HScrollBar
@onready var v_scroll_bar: VScrollBar = $VScrollBar

var min_zoom = 1.0
var max_zoom = 15.0
var zoom_step = 0.1
var zoom = 1.0
var original_canvas_width: int
var original_canvas_height: int
var virtual_space_h: int
var virtual_space_v: int
var virtual_space_pos: Vector2
var last_mouse_pos: Vector2 = Vector2.ZERO  # Store the last mouse position
#var last_mouse_pos: Vector2 = Vector2(size.x/2, size.y/2)  # Store the last mouse position

func _ready():
    original_canvas_width = canvas.size.x
    original_canvas_height = canvas.size.y
    
    virtual_space_h = size.x
    virtual_space_v = size.y
    virtual_space_pos = Vector2(0,0)
    update_scrollbars()

func _input(event):
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_WHEEL_UP:
            zoom_canvas(event.global_position, zoom_step)
        elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
            zoom_canvas(event.global_position, -zoom_step)

func zoom_canvas(mouse_position: Vector2, zoom_delta: float):
    var new_zoom = clamp(zoom + zoom_delta, min_zoom, max_zoom)
    if new_zoom == zoom:
        return

    var scale_factor = new_zoom / zoom
    zoom = new_zoom

    # Get mouse position relative to canvas before scaling
    var mouse_offset = mouse_position - canvas.position
    var old_size = canvas.size
    var old_space = size
    var new_size = old_size * scale_factor
    var new_space = old_space * scale_factor

    # Adjust the position based on zoom around the mouse cursor
    var zoom_offset = mouse_offset * (1 - scale_factor)

    canvas.size = new_size
    canvas.position += zoom_offset

    virtual_space_h = new_space.x
    virtual_space_v = new_space.y
    virtual_space_pos += zoom_offset
    # Update the scrollbars to match the new canvas size and zoom
    update_scrollbars()

    # Store the last mouse position (relative to the canvas)
    last_mouse_pos = mouse_position

func update_scrollbars():
    # Calculate the ratio of the canvas size relative to the control size
    var canvas_size_x_ratio: float = (original_canvas_width / size.x)
    var canvas_size_y_ratio: float = (original_canvas_height / size.y)
    
    # Update the scrollbar page values based on zoom level
    h_scroll_bar.page = (canvas_size_x_ratio / zoom) * h_scroll_bar.max_value
    v_scroll_bar.page = (canvas_size_y_ratio / zoom) * v_scroll_bar.max_value
    
    # Calculate the relative position adjustments based on the last mouse position
    #var x = (last_mouse_pos.x - canvas.position.x) / canvas.size.x
    #var y = (last_mouse_pos.y - canvas.position.y) / canvas.size.y

    var x = (last_mouse_pos.x - virtual_space_pos.x) / virtual_space_h
    var y = (last_mouse_pos.y - virtual_space_pos.y) / virtual_space_v
    # Update the scrollbar values based on mouse position relative to canvas
    h_scroll_bar.value = (h_scroll_bar.max_value - h_scroll_bar.page) - (x * (h_scroll_bar.max_value - h_scroll_bar.page))
    v_scroll_bar.value = (v_scroll_bar.max_value - v_scroll_bar.page) - (y * (v_scroll_bar.max_value - v_scroll_bar.page))extends Control

@onready var canvas: TextureRect = $Canvas
@onready var h_scroll_bar: HScrollBar = $HScrollBar
@onready var v_scroll_bar: VScrollBar = $VScrollBar

var min_zoom = 1.0
var max_zoom = 15.0
var zoom_step = 0.1
var zoom = 1.0
var original_canvas_width: int
var original_canvas_height: int
var virtual_space_h: int
var virtual_space_v: int
var virtual_space_pos: Vector2
var last_mouse_pos: Vector2 = Vector2.ZERO  # Store the last mouse position
#var last_mouse_pos: Vector2 = Vector2(size.x/2, size.y/2)  # Store the last mouse position

func _ready():
    original_canvas_width = canvas.size.x
    original_canvas_height = canvas.size.y
    
    virtual_space_h = size.x
    virtual_space_v = size.y
    virtual_space_pos = Vector2(0,0)
    update_scrollbars()

func _input(event):
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_WHEEL_UP:
            zoom_canvas(event.global_position, zoom_step)
        elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
            zoom_canvas(event.global_position, -zoom_step)

func zoom_canvas(mouse_position: Vector2, zoom_delta: float):
    var new_zoom = clamp(zoom + zoom_delta, min_zoom, max_zoom)
    if new_zoom == zoom:
        return

    var scale_factor = new_zoom / zoom
    zoom = new_zoom

    # Get mouse position relative to canvas before scaling
    var mouse_offset = mouse_position - canvas.position
    var old_size = canvas.size
    var old_space = size
    var new_size = old_size * scale_factor
    var new_space = old_space * scale_factor

    # Adjust the position based on zoom around the mouse cursor
    var zoom_offset = mouse_offset * (1 - scale_factor)

    canvas.size = new_size
    canvas.position += zoom_offset

    virtual_space_h = new_space.x
    virtual_space_v = new_space.y
    virtual_space_pos += zoom_offset
    # Update the scrollbars to match the new canvas size and zoom
    update_scrollbars()

    # Store the last mouse position (relative to the canvas)
    last_mouse_pos = mouse_position

func update_scrollbars():
    # Calculate the ratio of the canvas size relative to the control size
    var canvas_size_x_ratio: float = (original_canvas_width / size.x)
    var canvas_size_y_ratio: float = (original_canvas_height / size.y)
    
    # Update the scrollbar page values based on zoom level
    h_scroll_bar.page = (canvas_size_x_ratio / zoom) * h_scroll_bar.max_value
    v_scroll_bar.page = (canvas_size_y_ratio / zoom) * v_scroll_bar.max_value
    
    # Calculate the relative position adjustments based on the last mouse position
    #var x = (last_mouse_pos.x - canvas.position.x) / canvas.size.x
    #var y = (last_mouse_pos.y - canvas.position.y) / canvas.size.y

    var x = (last_mouse_pos.x - virtual_space_pos.x) / virtual_space_h
    var y = (last_mouse_pos.y - virtual_space_pos.y) / virtual_space_v
    # Update the scrollbar values based on mouse position relative to canvas
    h_scroll_bar.value = (h_scroll_bar.max_value - h_scroll_bar.page) - (x * (h_scroll_bar.max_value - h_scroll_bar.page))
    v_scroll_bar.value = (v_scroll_bar.max_value - v_scroll_bar.page) - (y * (v_scroll_bar.max_value - v_scroll_bar.page))

Scaling the canvas to the mouse position is smooth and works well, but the scroll bars still don't sync well with it.

Update2: I think this is more correct, however:

  • x and y are correct for zooming in, but the position of the scrolls does not change when zooming out
  • When changing the position of the mouse and applying the zoom in, the scrolls move directly in proportion to the position of the mouse to the new position, while this should be gradual.
    extends Control
    
    @onready var canvas: TextureRect = $Canvas
    @onready var h_scroll_bar: HScrollBar = $HScrollBar
    @onready var v_scroll_bar: VScrollBar = $VScrollBar
    
    var min_zoom = 1.0
    var max_zoom = 15.0
    var zoom_step = 0.1
    var zoom = 1.0
    var original_canvas_width: int
    var original_canvas_height: int
    #var last_mouse_pos: Vector2 = Vector2.ZERO  # Store the last mouse position
    var last_mouse_pos: Vector2 = Vector2(size.x/2, size.y/2)  # Store the last mouse position
    
    func _ready():
    	original_canvas_width = canvas.size.x
    	original_canvas_height = canvas.size.y
    	update_scrollbars()
    
    func _input(event):
    	if event is InputEventMouseButton:
    		if event.button_index == MOUSE_BUTTON_WHEEL_UP:
    			zoom_canvas(event.global_position, zoom_step)
    		elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
    			zoom_canvas(event.global_position, -zoom_step)
    
    func zoom_canvas(mouse_position: Vector2, zoom_delta: float):
    	var new_zoom = clamp(zoom + zoom_delta, min_zoom, max_zoom)
    	if new_zoom == zoom:
    		return
    
    	var scale_factor = new_zoom / zoom
    	zoom = new_zoom
    
    	# Get mouse position relative to canvas before scaling
    	var mouse_offset = mouse_position - canvas.position
    	var old_size = canvas.size
    	var new_size = old_size * scale_factor
    
    	# Adjust the position based on zoom around the mouse cursor
    	var zoom_offset = mouse_offset * (1 - scale_factor)
    	canvas.size = new_size
    	canvas.position += zoom_offset
    
    	# Update the scrollbars to match the new canvas size and zoom
    	update_scrollbars()
    
    	# Store the last mouse position (relative to the canvas)
    	last_mouse_pos = mouse_position
    
    func update_scrollbars():
    	# Calculate the ratio of the canvas size relative to the control size
    	var canvas_size_x_ratio: float = (original_canvas_width / size.x)
    	var canvas_size_y_ratio: float = (original_canvas_height / size.y)
    	
    	# Update the scrollbar page values based on zoom level
    	h_scroll_bar.page = (canvas_size_x_ratio / zoom) * h_scroll_bar.max_value
    	v_scroll_bar.page = (canvas_size_y_ratio / zoom) * v_scroll_bar.max_value
    	
    	# Calculate the relative position adjustments based on the last mouse position
    	var x = (last_mouse_pos.x - position.x) / size.x # Need to be modified
    	var y = (last_mouse_pos.y - position.y) / size.y # Need to be modified
    
    	# Update the scrollbar values based on mouse position relative to canvas
    	h_scroll_bar.value = (h_scroll_bar.max_value - h_scroll_bar.page) - (x * (h_scroll_bar.max_value - h_scroll_bar.page))
    	v_scroll_bar.value = (v_scroll_bar.max_value - v_scroll_bar.page) - (y * (v_scroll_bar.max_value - v_scroll_bar.page))