Drag Resize Implementation

This document explains the pointer-event handling, bounds computation, and clamping logic used by <vn-resize-box> during drag-to-resize interactions.


1. Overview

The resize interaction is driven by a small visual handle in the bottom corner of the container on the inline-end side — bottom-right in LTR, bottom-left under dir="rtl". The user presses on the handle and drags to resize both width and height at once, outwards along the diagonal (SE in LTR, SW in RTL).

The whole interaction runs on Pointer Events. Mouse, touch and pen are one code path, with one set of coordinates (e.clientX / e.clientY) and one termination contract — there is no mouse/touch branch anywhere in the component.

1.1 Pointer Capture

All the listeners — pointerdown, pointermove, pointerup, pointercancel, lostpointercapture — live permanently on the handle. Nothing is attached or detached per drag, because _onDown calls setPointerCapture(): from that moment every event for that pointer is routed to the handle regardless of what the pointer is over, including areas outside the element and outside the window.

Three consequences worth stating, because they are what the capture model buys:

touch-action: none on .vn-resize-handle is a required part of this model, not a polish: without it the browser claims a touch on the handle for panning and the gesture scrolls the page instead of resizing.


2. Event Flow

2.1 Drag Start (_onDown)

Triggered by pointerdown on the .vn-resize-handle element.

  1. Guards: return unless e.button === 0 (touch and pen contacts report 0, so this needs no input-type branch), and return if a drag is already live.
  2. Take capture: record e.pointerId and call setPointerCapture() on the handle.
  3. Snapshot current size: getBoundingClientRect() on the <vn-resize-box> custom element — the element the inline size is written to.
  4. Freeze bounds: Compute minW, minH, maxW, maxH once. This prevents feedback loops where the parent rect shifts mid-drag.
  5. Override CSS max: Temporarily set inline maxWidth / maxHeight so CSS rules don't cap values during the drag.
  6. Add dragging class: .vn-resize-box--dragging disables child pointer events and shows se-resize cursor everywhere.
  7. Emit event: vn-resize-start, with the size at press time as { width, height }.

The button guard in step 0 exists because a secondary press must leave the platform context menu alone, and because its matching release is a contextmenu rather than a clean pointer-up — a drag started there would never terminate.

2.2 Drag Move (_onMove)

Triggered by pointermove on the handle (which, thanks to capture, means anywhere on screen). Events carrying a different pointerId than the captured one are ignored.

  1. Compute delta: dx = clientX - startX, dy = clientY - startY.
  2. Clamp new size: newW = clamp(startW + dx, minW, maxW), same for height.
  3. Coalesce: The clamped size is recorded as pending and applied by a single requestAnimationFrame callback (_applyPendingSize). Raw input events (125–1000 Hz on high-rate mice) collapse to at most one style write and one vn-resize-move dispatch per frame; the last pointer position within the frame wins.
  4. Apply (per frame): Set style.width and style.height on the container (rounded to whole pixels).
  5. Emit event (per frame): vn-resize-move with { width, height } detail.

2.3 Drag End (_endDrag)

_endDrag(fireEvent) is the centralized cleanup used by two callers:

Steps:

  1. Release capture: _releasePointer() drops the capture and clears the active pointerId. It is guarded by hasPointerCapture(), because the capture is already gone on the two paths that do not end in a pointerup: a pointercancel, and a disconnect that removed the capturing element from the document. Everything after this point is skipped when no drag was active, so a host-set body userSelect is never clobbered.
  2. Cancel + flush pending move: A queued move rAF is cancelled and the last pending size is applied silently (no vn-resize-move; the closing vn-resize-end carries the final size).
  3. Remove dragging class: .vn-resize-box--dragging is removed.
  4. Restore user-select: document.body.style.userSelect is cleared.
  5. Normalize (fireEvent: true only): Read final getBoundingClientRect() and write rounded values back to inline style. The disconnect path skips this — a detached element measures 0×0 — and only restores the bounds.
  6. Restore bounds: _applyConstraints() rewrites the min-* / max-* inline styles from the attributes, replacing the temporary values frozen at drag-start. A max-width set during the drag takes effect here, once the frozen bound has done its job.
  7. Emit event (fireEvent: true only): vn-resize-end with { width, height } detail.

2.4 Lost Capture (_onLostCapture)

lostpointercapture fires whenever the handle stops holding the capture — for any reason, including the ordinary ones. It is the backstop for a capture that goes away with no pointerup and no pointercancel to close it: the browser dropping the capture for a mouse when the window loses focus is the case that used to need a window.blur listener.

It is idempotent by construction, which is what makes a blanket backstop safe:


3. Bounds Computation

3.1 Minimum Bounds

minW = parsed min-width attribute  || DEFAULT_MIN_WIDTH  (100)
minH = parsed min-height attribute || DEFAULT_MIN_HEIGHT (100)

3.2 Inline Direction

The direction is resolved once per interaction, at press time, from getComputedStyle(this).direction, and stored as a sign: _dirSign = rtl ? -1 : 1. It is resolved once for the same reason the bounds are — it cannot change mid-gesture, and a computed-style read per move would be a layout read on the hot path.

The sign inverts the horizontal delta:

newW = clamp(startW + dirSign * (clientX - startX), minW, maxW)

Under dir="rtl" the handle sits in the bottom-left corner (inset-inline-end does that on its own), so dragging left grows the box. The vertical axis is unaffected — vertical writing modes are explicitly not supported.

3.3 Maximum Bounds

Maximum bounds are the minimum of:

  1. The explicit max-width / max-height attribute (if set, otherwise Infinity).
  2. A parent-relative ceiling: the parent container's available space minus a 12px margin.
inlineOffset = rtl ? parentRect.right - boxRect.right
                   : boxRect.left - parentRect.left
parentMaxW = parentRect.width  - inlineOffset - HANDLE_BOUND_MARGIN
parentMaxH = parentRect.height - (boxRect.top - parentRect.top) - HANDLE_BOUND_MARGIN
maxW = min(attrMaxW, parentMaxW)
maxH = min(attrMaxH, parentMaxH)

inlineOffset is the room behind the box on the axis it does not grow along. In LTR the box is anchored on the left and grows right, so that is the distance from the parent's left edge; in RTL it is anchored on the right and grows left, so the term flips to the distance from the parent's right edge. Getting this wrong is invisible in LTR and clamps an RTL box at roughly its starting width.

HANDLE_BOUND_MARGIN is a fixed 12px. It exists so the box cannot be dragged flush against the parent's far edge, where the handle would sit half outside the clickable area and the next drag would be hard to start. It is part of the component's feel and is deliberately not configurable.

Additionally, maxW and maxH are raised to at least the current size to prevent a visual jump on the first pointer move:

maxW = max(maxW, startW)
maxH = max(maxH, startH)

3.4 Why Freeze at Drag-Start

Computing bounds every frame during drag causes the parent's layout to respond to the resizing child, shifting its own getBoundingClientRect(). This feedback loop manifests as jittery gaps or overshooting. By freezing bounds once at press time, the drag stays smooth and predictable.


4. Clamping

A simple utility clamps any value to the frozen [min, max] range:

function clamp(n, min, max) {
    return Math.max(min, Math.min(max, n));
}

Both width and height are clamped independently on every move frame.


5. Touch and Pen Support

There is nothing input-type-specific to support. pointerdown / pointermove / pointerup / pointercancel are delivered for mouse, touch and pen alike, all carry clientX / clientY directly, and all report button === 0 for a primary contact. Two things make touch work in particular:


6. Safety Mechanisms

Mechanism Purpose
pointercancel handling End the drag when the system takes the gesture away
lostpointercapture handling End the drag when the capture is lost for any other reason (e.g. the window losing focus mid-drag)
pointerId matching A second contact on the handle cannot steer or end a live drag
disconnectedCallback cleanup Release the capture and clear the drag state if the element is removed mid-drag
user-select: none on body Prevent text selection while dragging
.vn-resize-box--dragging class Disable child pointer events to prevent interference
Temporary max-width/max-height override Prevent CSS rules from fighting inline drag values

7. Performance Considerations