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:
- The drag always terminates. A captured pointer ends in a
pointerupor apointercancel, and both run the same_onUp→_endDragpath.pointercancelis what arrives when the system takes the gesture over — an incoming call, a system edge-swipe, too many simultaneous contacts.lostpointercaptureis the backstop underneath both: whatever else drops the capture ends the drag too (see §2.4). There is no way to end up mid-drag withuser-select: nonestuck page-wide. - No document-level listeners and no
window.blursafety release. The document listeners existed only to see moves and releases that happened off the element, which capture delivers to the handle directly. Theblurlistener was there to catch a mouse drag abandoned when the window lost focus;lostpointercapturecovers that case as part of the pointer model rather than as a side channel. - One gesture at a time.
_onDownrecordse.pointerId, and_onMove/_onUpignore any event carrying a different one, so a second finger landing on the handle can neither steer nor end a drag in flight.
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.
- Guards: return unless
e.button === 0(touch and pen contacts report0, so this needs no input-type branch), and return if a drag is already live. - Take capture: record
e.pointerIdand callsetPointerCapture()on the handle. - Snapshot current size:
getBoundingClientRect()on the<vn-resize-box>custom element — the element the inline size is written to. - Freeze bounds: Compute
minW,minH,maxW,maxHonce. This prevents feedback loops where the parent rect shifts mid-drag. - Override CSS max: Temporarily set inline
maxWidth/maxHeightso CSS rules don't cap values during the drag. - Add dragging class:
.vn-resize-box--draggingdisables child pointer events and showsse-resizecursor everywhere. - 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.
- Compute delta:
dx = clientX - startX,dy = clientY - startY. - Clamp new size:
newW = clamp(startW + dx, minW, maxW), same for height. - Coalesce: The clamped size is recorded as pending and applied by a single
requestAnimationFramecallback (_applyPendingSize). Raw input events (125–1000 Hz on high-rate mice) collapse to at most one style write and onevn-resize-movedispatch per frame; the last pointer position within the frame wins. - Apply (per frame): Set
style.widthandstyle.heighton the container (rounded to whole pixels). - Emit event (per frame):
vn-resize-movewith{ width, height }detail.
2.3 Drag End (_endDrag)
_endDrag(fireEvent) is the centralized cleanup used by two callers:
_onUp(pointeruporpointercancelfor the captured pointer) →_endDrag(true)_onLostCapture(see §2.4) →_endDrag(true)disconnectedCallback→_endDrag(false)— removing the element mid-drag must not leaveuserSelect: noneor the dragging class stuck page-wide, and fires no event
Steps:
- Release capture:
_releasePointer()drops the capture and clears the activepointerId. It is guarded byhasPointerCapture(), because the capture is already gone on the two paths that do not end in apointerup: apointercancel, 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-setbodyuserSelectis never clobbered. - Cancel + flush pending move: A queued move rAF is cancelled and the last pending size is applied silently (no
vn-resize-move; the closingvn-resize-endcarries the final size). - Remove dragging class:
.vn-resize-box--draggingis removed. - Restore user-select:
document.body.style.userSelectis cleared. - Normalize (
fireEvent: trueonly): Read finalgetBoundingClientRect()and write rounded values back to inline style. The disconnect path skips this — a detached element measures 0×0 — and only restores the bounds. - Restore bounds:
_applyConstraints()rewrites themin-*/max-*inline styles from the attributes, replacing the temporary values frozen at drag-start. Amax-widthset during the drag takes effect here, once the frozen bound has done its job. - Emit event (
fireEvent: trueonly):vn-resize-endwith{ 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:
- On every ordinary release the capture is already dropped and
_activePointerIdcleared by the time this fires, so thepointerIdguard drops the event and nothing runs twice. - On a disconnect the capture is released too, but
disconnectedCallbackowns that cleanup — it is the path that does not measure a detached element (0×0) or report that as the final size._onLostCapturetherefore returns early whenisConnectedis false, whichever of the two runs first.
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:
- The explicit
max-width/max-heightattribute (if set, otherwiseInfinity). - 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:
touch-action: noneon.vn-resize-handle, so the browser does not claim the touch for panning;pointercancelhandling, so a gesture the system takes over (an incoming call, an edge swipe, a third finger) ends the drag cleanly instead of leaving it live.
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
- Raw input events are coalesced into a single
requestAnimationFramecallback (see §2.2), so the input rate — 125–1000 Hz on high-rate mice — never drives the style write or thevn-resize-movedispatch rate. - The DOM write is minimal: two
style.width/style.heightassignments per frame. - Children (e.g.,
VanillaGrid) should use their ownResizeObserverto react to container size changes, keeping the drag handler decoupled from content relayout.