Draggable regions
Mark a frame `reactive_frame`, a box in it `reactive_box`, and a point `reactive_point`: they drag with a mouse, a finger or the keyboard, stay inside the frame, and their fields always hold where they are as fractions of it — a crop box, a focal point, a hotspot, a selection.
Try it#
Drag the box to move it, or a handle to resize it, and drag the red point to set the focus. Pick a preset to lock the shape: the box re-fits around its centre. Then try the keyboard: Tab to the box, ← → ↑ ↓ to move it, Alt + arrows to resize it, Shift for bigger steps. The fields follow as you drag; each release saves once.
Saved: 45% × 60% at 20%, 15% · focus 50%, 40%
# frozen_string_literal: true
# The draggable-regions demo (issue #416): a crop box over a picture, with
# aspect presets and a focal point inside it. The box drags and resizes with a
# mouse, a pen, a finger or the keyboard; the fields below follow it as it
# moves, and each release sends :crop (the point: :focus) with the region as
# fractions of the picture. The region lives in signed state — no database —
# so every visitor gets their own.
class RegionPickerComponent < Phlex::HTML
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
PRESETS = { '' => 'Free', '16:9' => '16:9', '1:1' => 'Square', '4:5' => 'Portrait' }.freeze
CROP = { 'x' => 0.2, 'y' => 0.15, 'width' => 0.45, 'height' => 0.6 }.freeze
FOCUS = { 'x' => 0.5, 'y' => 0.4 }.freeze
# The look is the app's: the box dims what it leaves out, and the styling
# hooks (data-reactive-dragging, data-reactive-frame-active) light it up.
BOX_CSS = 'outline-2 outline-white shadow-[0_0_0_9999px_rgb(0_0_0/0.45)] focus-visible:outline-4 ' \
'focus-visible:outline-primary data-[reactive-dragging]:outline-primary'
HANDLE_CSS = 'size-3 rounded-full border-2 border-white bg-primary focus:outline-none ' \
'data-[reactive-dragging]:scale-150'
POINT_CSS = 'size-4 rounded-full border-2 border-white bg-error shadow focus:outline-none ' \
'focus-visible:ring-4 focus-visible:ring-error/50 data-[reactive-dragging]:scale-125'
reactive_state :crop, :focus, :aspect
# The checked preset is a root field named `aspect`: every action collects
# it, so the server keeps it and renders it checked again.
action :crop, params: { x: :float, y: :float, width: :float, height: :float, aspect: :string }
action :focus, params: { x: :float, y: :float, aspect: :string }
def initialize(crop: CROP, focus: FOCUS, aspect: '')
@crop = crop
@focus = focus
@aspect = aspect
end
def id = 'region-picker'
def crop(x:, y:, width:, height:, aspect: nil)
return reply.morph unless inside?(x, width) && inside?(y, height)
@aspect = aspect.to_s
@crop = { 'x' => x, 'y' => y, 'width' => width, 'height' => height }
reply.morph
end
def focus(x:, y:, aspect: nil)
return reply.morph unless [x, y].all? { it.between?(0, 1) }
@aspect = aspect.to_s
@focus = { 'x' => x, 'y' => y }
reply.morph
end
def view_template
div(**reactive_root(class: 'flex flex-col gap-4', data: { testid: 'region-picker' })) do
picture_frame
presets
fields
p(class: 'text-sm opacity-70', data: { testid: 'region-saved' }) { saved }
end
end
private
# The schema casts the fractions; it does not range-check them. A forged
# release outside the picture changes nothing (the helper would refuse to
# render it). The fields round to 4 decimals, so x + width may reach 1.0001.
def inside?(at, size) = at.between?(0, 1) && size.positive? && at + size <= 1.0001
def picture_frame
div(**mix(reactive_frame, class: 'relative overflow-hidden rounded-box select-none',
data: { testid: 'region-frame' })) do
img(src: '/drag-demo/landscape.svg', alt: 'A landscape to crop', class: 'block w-full', draggable: 'false')
div(**mix(crop_box, class: BOX_CSS, aria: { label: 'Crop region' }, data: { testid: 'region-box' })) do
%i[nw n ne e se s sw w].each { span(**mix(reactive_box_handle(it), class: HANDLE_CSS)) }
span(**mix(focal_point, class: POINT_CSS, aria: { label: 'Focal point' }, data: { testid: 'region-point' }))
end
end
end
def crop_box
reactive_box(fields: { x: 'crop[x]', y: 'crop[y]', width: 'crop[width]', height: 'crop[height]' },
value: @crop, aspect: 'aspect', min: 0.1, action: :crop)
end
def focal_point
reactive_point(fields: { x: 'focus[x]', y: 'focus[y]' }, value: @focus,
within: '[data-reactive-box]', action: :focus)
end
def presets
div(class: 'join', role: 'radiogroup', aria: { label: 'Aspect ratio' }) do
PRESETS.each do |ratio, label|
input(type: 'radio', name: 'aspect', value: ratio, checked: ratio == @aspect, aria: { label: },
class: 'join-item btn btn-sm', data: { testid: "region-aspect-#{label.downcase}" })
end
end
end
# The fields the box writes: visible here, hidden in a real form.
def fields
div(class: 'grid grid-cols-3 gap-2 text-xs sm:grid-cols-6') do
@crop.each { |key, value| field("crop[#{key}]", value) }
@focus.each { |key, value| field("focus[#{key}]", value) }
end
end
def field(name, value)
label(class: 'flex flex-col gap-1') do
span(class: 'font-mono opacity-70') { name }
input(type: 'text', name:, value:, readonly: true, class: 'input input-xs font-mono',
data: { testid: "region-#{name.delete(']').tr('[', '-')}" })
end
end
def saved
c = @crop.transform_values { (it * 100).round }
f = @focus.transform_values { (it * 100).round }
"Saved: #{c['width']}% × #{c['height']}% at #{c['x']}%, #{c['y']}% · focus #{f['x']}%, #{f['y']}%"
end
endA box in a frame#
reactive_frame marks the coordinate space: its padding box is 0..1 on each axis. reactive_box goes on an element inside it, with the names of the fields that hold it and where it is:
class CropPicker < Phlex::HTML
include Phlex::Reactive::Component
reactive_record :photo
action :crop, params: { x: :float, y: :float, width: :float, height: :float }
def crop(x:, y:, width:, height:)
authorize! @photo, :update?
# The schema casts; it does not range-check (see below).
return reply.morph unless inside?(x, width) && inside?(y, height)
@photo.update!(crop: { x:, y:, width:, height: })
reply.morph
end
def view_template
div(**reactive_root) do
div(**mix(reactive_frame, class: "relative")) do
img(src: @photo.url, alt: "", class: "block w-full")
div(**reactive_box(
fields: { x: "crop[x]", y: "crop[y]", width: "crop[width]", height: "crop[height]" },
value: @photo.crop, # { x: 0.1, y: 0.1, width: 0.5, height: 0.4 }
min: 0.05,
action: :crop
)) do
%i[nw n ne e se s sw w].each { span(**reactive_box_handle(it)) }
end
end
@photo.crop.each { |key, value| input(type: "hidden", name: "crop[#{key}]", value:) }
end
end
private
# The fields round to 4 decimals, so x + width may reach 1.0001.
def inside?(at, size) = at.between?(0, 1) && size.positive? && at + size <= 1.0001
endThe helper renders the box's left, top, width and height as percentages of the frame from value:, so the first paint is right with no JavaScript, and a re-render with new values moves it. The box is clamped to the frame and never gets smaller than min: (a fraction of the frame; 0.05 by default).
Fractions, not pixels: they survive a resize, a responsive layout and a morph, and the server turns them into pixels with the dimensions it already knows (x * image.width).
The frame must be positioned — relative, or absolute for an overlay — which is left to you. The fields can be anywhere in the root: hidden inputs, or visible ones as in the demo.
Handles#
reactive_box_handle(edge) goes on an element inside the box. A press on it resizes from that edge or corner, the opposite side staying put; a press anywhere else on the box moves it. The edges are :nw, :n, :ne, :e, :se, :s, :sw and :w, and the helper places each one on its edge with the matching resize cursor. Its size and look are yours:
span(**mix(reactive_box_handle(:se), class: "size-3 rounded-full bg-white"))A drag starts after a 4 px move, so a click still works. The box, its handles and the point are touch-action: none: a finger drag moves them instead of scrolling the page. A press on the frame outside the box does nothing.
A point#
reactive_point is a point — its centre — that moves inside the frame, with an x and a y field:
span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: { x: 0.5, y: 0.5 }))To keep a point inside a box (a focal point inside a crop), put it in the box and pass within:, a selector for the element it sits in. Its fractions are then of that element, and it moves with the box:
div(**reactive_box(...)) do
span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: @focus,
within: "[data-reactive-box]"))
endThe point has to sit inside the within: element: its percentages are of that element, which is what lets the server place it with no JavaScript.
Locking the aspect ratio#
aspect: locks the box's shape — its width over its height as it appears, in pixels. Pass a Numeric (16/9r, 1.5) for a fixed ratio, or the name of a field whose value is "16:9", a decimal, or "" for free:
div(**reactive_box(fields: { ... }, value: @crop, aspect: "aspect")) { ... }
{ "" => "Free", "16:9" => "16:9", "1:1" => "Square" }.each do |ratio, label|
label { input(type: "radio", name: "aspect", value: ratio, checked: ratio == @aspect); plain label }
endThe field is read when a drag starts. On a corner the axis you moved more decides the size; on an edge the other side grows around the box's centre. When the field changes — a preset is picked, or set_value writes it — the box re-fits to the new ratio: the same area, centred where it was, inside the frame. A re-fit is a change like a release: the fields get change and the action is sent.
The aspect field is an ordinary field of the root, so every action collects it. Declare it in the action's params (aspect: :string) to keep the preset and render it checked again, as the demo does.
The fields and the action#
While a box or point moves, the client writes its fields the way set_value does: the live .value, a fraction rounded to 4 decimals, and a bubbling input — at most once per field per animation frame. So reactive_show, compute reducers and other bindings follow the box as it moves. A single change fires on each field when you let go.
action: is sent once on release, through the root's own pipeline: the signed token, the request queue, reactive:before-dispatch, busy: (the same values as on(busy:)) and the reply. It gets these params; declare them as :float:
| Element | Params |
|---|---|
reactive_box | x, y, width, height — fractions of the frame |
reactive_point | x, y — fractions of the frame, or of its within: element |
A drag that ends where it started sends nothing. Without action: the binding is client-only: it needs no token and works on a Phlex::Reactive::ClientBindings root, where a form submit sends the fields.
Check the range before you store it. The schema casts the numbers but does not range-check them, so a forged request can post x: 2. reactive_box refuses to render a box outside the frame, so a stored bad value would break every later render of it. Refuse anything outside 0..1, a width or height of 0, or a box whose x + width or y + height passes 1, in the action or with a model validation. The fields round to 4 decimals, so allow a sum of up to 1.0001, as the helper does. The inside? above (and the demo's) does this.
Keyboard and screen readers#
The box and the point are focusable (tabindex="0"); a handle is focusable with a press, but is not a tab stop. On a focused box or point:
| Key | Does |
|---|---|
| ← → ↑ ↓ | move it 1% of the frame |
| Shift + arrows | move it 10% |
| Alt + arrows | resize the box from its bottom-right corner, or from the focused handle |
| Escape | while an arrow is held (or during a pointer drag), put it back where it started |
Each arrow writes the fields (input); letting go of the key is the release: one change, and one request for action:, however long the key was held. Each step is announced through an aria-live="polite" region: "Moved to 20%, 15%", "Resized to 45% by 60%", "Cancelled". Give the box and the point an aria-label that says what they are. Translate the words with messages: — {x}, {y}, {width} and {height} are filled in as whole percents, so the % sign is yours to place:
reactive_box(..., messages: {
moved: t(".moved"), # "Verschoben nach {x} %, {y} %"
resized: t(".resized"),
cancelled: t(".cancelled")
})Styling the drag#
The look is yours. While a pointer drag is on, the client sets these attributes — style them in CSS, or with Tailwind's data-* variants as the demo does:
| Attribute | On |
|---|---|
data-reactive-frame-active | the frame |
data-reactive-dragging | the box, handle or point being pressed |
The helpers write an inline style (the position, position: absolute, touch-action: none, the cursor). To add your own inline style, mix it in after the helper's, as a String:
div(**mix(reactive_box(...), style: "z-index: 10;", class: "outline-2 outline-white"))A border on the box is inside it (box-sizing: border-box), so the box covers exactly the region its fields describe. The demo uses an outline and a wide box-shadow to dim what the crop leaves out.
Morphs#
Where a box or point is lives in its inline style, so a server re-render that changes value: moves it — after a reply, a broadcast or a page refresh. Fields outside what the morph rendered are brought along.
A morph that arrives during a drag does not interrupt it: the dragged element's style and its fields are held until release, like a field being typed in. The drag's values win, and its release sends them.
Reference#
reactive_frame
reactive_box(fields:, value:, aspect: nil, min: 0.05, action: nil, busy: nil, messages: nil)
reactive_box_handle(edge) # :nw :n :ne :e :se :s :sw :w
reactive_point(fields:, value:, within: nil, action: nil, busy: nil, messages: nil)These are checked when the view renders: missing or unknown fields: keys, a value: outside 0..1 (or a box of no size, or one that leaves the frame), an unknown handle edge, a blank within:, a min: that is not above 0, an aspect: that is neither a positive Numeric nor a field name (a ratio written as a String, "16:9", says to pass 16/9r), busy: without action:, or action: on a client-only view raises an ArgumentError that says how to fix it. Under verbose_errors, an action the component does not declare raises too, as on does: once the component declares any action.
The drag code is the client's drag feature module (about 3.5 KB gzipped). The default client bundles it; with the split client (phlex/reactive/core) a page without a frame never loads it.