ImageCrop
A crop box over a picture, dragged and resized by its corners or the keys, with the crop in hidden inputs a form sends.
Demo
Drag the box or its corners.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/image_crop.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/image_crop.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/image_crop.qwe
// A picture 800 by 500 pixels drawn in SVG, so the demo needs no image files and its size is known.let image_crop_demo_pic = `data:image/svg+xml,${url_encode( `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="800" height="500">` + `<rect width="800" height="500" fill="#9fd8c8"/><circle cx="560" cy="160" r="70" fill="#ffe9a0"/>` + `<path d="M0 360 Q 200 240 420 340 T 800 300 V 500 H 0 Z" fill="#1f6f68"/>` + `<circle cx="250" cy="250" r="60" fill="#f3b5c8"/><circle cx="230" cy="235" r="8" fill="#333"/><circle cx="270" cy="235" r="8" fill="#333"/></svg>`)}`component ImageCropDemoLive() { state got = null <div.demo-stack> <ImageCrop name="avatar" src={image_crop_demo_pic} alt="A drawing of a face on a hill" ratio={1} on_change={(c) => { got = c }}/> <p.demo-note id="image-crop-demo-note">{got == null ? "Drag the box or its corners." : `Crop ${got.w} × ${got.h} at ${got.x}, ${got.y}.`}</p> </div>}view ImageCropDemo() = <div.demo-stack> <div style="max-width: 480px"><ImageCropDemoLive/></div> <div style="max-width: 320px"> <ImageCrop name="banner" src={image_crop_demo_pic} alt="The same drawing, any shape" crop={{ x: 100, y: 50, w: 400, h: 200 }}/> </div> </div>Source
ui/image_crop.qwe
// ImageCrop: a picture with a crop box over it, to drag and resize by its// corners, with what is outside it dimmed. The crop is kept in the picture's// own pixels in four hidden inputs, `name`_x, `name`_y, `name`_w and `name`_h,// so a plain <form> sends it and the server can cut it with image.convert. With// the keyboard: Tab to the box, the arrow keys move it, Shift with the arrows// resizes it.//// <form method="post" action="/avatar">// <ImageCrop name="crop" src="/uploads/me.jpg" ratio={1} alt="Your photo"/>// </form>// route POST /avatar { form: { crop_x: int, crop_y: int, crop_w: int, crop_h: int } } =// image.convert("uploads/me.jpg", "uploads/me-sq.jpg", { crop: { x: crop_x, y: crop_y, w: crop_w, h: crop_h }, width: 400 })//// src, alt: the picture. name: the start of the hidden inputs' names ("crop").// ratio: the box's shape as width / height (16 / 9, 1...); null lets it be any.// crop: where the box starts, { x, y, w, h } in the picture's pixels; a centred// box over most of it by default. min: the box's least side, in the picture's// pixels (32). label: what screen readers call the box ("Crop area").// on_change(crop): inside one of your components, called with { x, y, w, h } in// pixels after each drag or key. The picture's size is read when it loads.import ui/theme.qwe use ui_clslet image_crop_css = css`.ui-image-crop { display: grid; gap: 8px; font: 13px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-image-crop-frame { position: relative; overflow: hidden; width: 100%; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); touch-action: none; user-select: none; }.ui-image-crop-frame img { display: block; width: 100%; height: auto; pointer-events: none; -webkit-user-drag: none; }.ui-image-crop-box { position: absolute; left: var(--x); top: var(--y); width: var(--w); height: var(--h); border: 1.5px solid #fff; cursor: move; outline: none; box-shadow: 0 0 0 9999px rgba(0, 0, 0, .5);}.ui-image-crop-box:focus-visible { border-color: var(--ui-accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .5), var(--ui-focus); }.ui-image-crop-box::before, .ui-image-crop-box::after { content: ""; position: absolute; pointer-events: none; border: 0 solid rgba(255, 255, 255, .45); }.ui-image-crop-box::before { inset: 0 33.33%; border-width: 0 1px; }.ui-image-crop-box::after { inset: 33.33% 0; border-width: 1px 0; }.ui-image-crop-handle { position: absolute; z-index: 1; width: 16px; height: 16px; border: 2px solid var(--ui-accent); border-radius: 3px; background: #fff; }.ui-image-crop-nw { left: -8px; top: -8px; cursor: nwse-resize; }.ui-image-crop-ne { right: -8px; top: -8px; cursor: nesw-resize; }.ui-image-crop-sw { left: -8px; bottom: -8px; cursor: nesw-resize; }.ui-image-crop-se { right: -8px; bottom: -8px; cursor: nwse-resize; }.ui-image-crop-size { color: var(--ui-muted); font-variant-numeric: tabular-nums; }`fn image_crop_clamp(v: num, lo: num, hi: num) -> num = v < lo ? lo : v > hi ? hi : v// A box moved or resized: s is the box when the drag began ({ x, y, w, h }, fractions of the// picture), mode "move" or a corner ("nw", "ne", "sw", "se"), dx and dy the pointer's move in// fractions; mw and mh the least width and height; k the height a width of 1 takes (null: any).fn image_crop_drag(s, mode: str, dx: num, dy: num, mw: num, mh: num, k: num?) { if mode == "move" { return { x: image_crop_clamp(s.x + dx, 0, 1 - s.w), y: image_crop_clamp(s.y + dy, 0, 1 - s.h), w: s.w, h: s.h } } let west = contains(mode, "w") let north = contains(mode, "n") let x1 = west ? image_crop_clamp(s.x + dx, 0, s.x + s.w - mw) : s.x let x2 = west ? s.x + s.w : image_crop_clamp(s.x + s.w + dx, s.x + mw, 1) let y1 = north ? image_crop_clamp(s.y + dy, 0, s.y + s.h - mh) : s.y let y2 = north ? s.y + s.h : image_crop_clamp(s.y + s.h + dy, s.y + mh, 1) if k == null { return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 } } // Held to its shape: the width leads, the height follows, both shrunk to fit the room. let room_h = north ? s.y + s.h : 1 - s.y let room_w = west ? s.x + s.w : 1 - s.x let w = min([x2 - x1, room_h / k, room_w]) let h = w * k { x: west ? s.x + s.w - w : s.x, y: north ? s.y + s.h - h : s.y, w, h }}component ImageCrop(src: str, alt: str = "", name: str = "crop", ratio: num? = null, crop: map? = null, min: int = 32, label: str = "Crop area", on_change = null, class: str? = null) { style global image_crop_css state nat_w = 0 state nat_h = 0 state box = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 } state frame = null state drag = null let k = ratio != null && nat_w > 0 ? nat_w / nat_h / ratio : null let mw = nat_w > 0 ? min / nat_w : 0.05 let mh = nat_h > 0 ? min / nat_h : 0.05 let px = nat_w > 0 ? { x: round(box.x * nat_w), y: round(box.y * nat_h), w: round(box.w * nat_w), h: round(box.h * nat_h) } : null let tell = () => { if px != null && on_change != null { on_change(px) } } // The picture's size, once known: the box starts where `crop` says, or centred, held to its ratio. let start = (w: num, h: num) => { if w > 0 && nat_w == 0 { nat_w = w nat_h = h let kk = ratio != null ? w / h / ratio : null box = crop != null ? { x: crop.x / w, y: crop.y / h, w: crop.w / w, h: crop.h / h } : kk == null ? { x: 0.1, y: 0.1, w: 0.8, h: 0.8 } : (0.8 * kk <= 0.8 ? { x: 0.1, y: (1 - 0.8 * kk) / 2, w: 0.8, h: 0.8 * kk } : { x: (1 - 0.8 / kk) / 2, y: 0.1, w: 0.8 / kk, h: 0.8 }) } } let begin = (e, mode: str) => { if e.button == 0 { e.preventDefault() e.stopPropagation() e.target.setPointerCapture(e.pointerId) drag = { mode, sx: e.clientX, sy: e.clientY, s: box } } } <div class={ui_cls(["ui-image-crop", class])}> <div.ui-image-crop-frame @mount={(el) => { frame = el }} @pointermove={(e) => { if drag != null { let r = frame.getBoundingClientRect() box = image_crop_drag(drag.s, drag.mode, (e.clientX - drag.sx) / r.width, (e.clientY - drag.sy) / r.height, mw, mh, k) } }} @pointerup={() => { if drag != null { drag = null; tell() } }} @pointercancel={() => { drag = null }}> <img src={src} alt={alt} draggable="false" @mount={(el) => { if el.complete { start(el.naturalWidth, el.naturalHeight) } }} @load={(e) => { // A load Quillo replays for a picture that finished first can come with no // picture behind it, and so no size: @mount has taken that one already. let w = e.target.naturalWidth if w != null { start(w, e.target.naturalHeight) } }}/> <div.ui-image-crop-box role="group" tabindex="0" aria-label={label} aria-description={px != null ? `${px.w} by ${px.h} pixels, at ${px.x}, ${px.y}. Arrow keys move it; Shift and the arrows resize it.` : null} style={`--x: ${round(box.x * 100, 3)}%; --y: ${round(box.y * 100, 3)}%; --w: ${round(box.w * 100, 3)}%; --h: ${round(box.h * 100, 3)}%`} @pointerdown={(e) => { begin(e, "move") }} @keydown={(e) => { let dx = match e.key { "ArrowLeft" => -0.02, "ArrowRight" => 0.02, _ => 0 } let dy = match e.key { "ArrowUp" => -0.02, "ArrowDown" => 0.02, _ => 0 } if dx != 0 || dy != 0 { e.preventDefault() box = image_crop_drag(box, e.shiftKey ? "se" : "move", dx, dy, mw, mh, k) tell() } }}> {for c in ["nw", "ne", "sw", "se"] => <span key={c} class={`ui-image-crop-handle ui-image-crop-${c}`} data-corner={c} aria-hidden="true" @pointerdown={(e) => { begin(e, c) }}></span>} </div> </div> <span.ui-image-crop-size aria-hidden="true">{px != null ? `${px.w} × ${px.h} px at ${px.x}, ${px.y}` : "Loading…"}</span> <input type="hidden" name={`${name}_x`} value={px != null ? px.x : ""}/> <input type="hidden" name={`${name}_y`} value={px != null ? px.y : ""}/> <input type="hidden" name={`${name}_w`} value={px != null ? px.w : ""}/> <input type="hidden" name={`${name}_h`} value={px != null ? px.h : ""}/> </div>}Related
CameraCaptureTakes a photo with the camera into a hidden input as a data URL, with retake, and says so when there is no camera.6 views · 6 downloadsImageCompareBefore and after pictures one over the other, with a divider to drag (or move with the keys) between them.6 views · 9 downloadsCoverImageA wide picture with a title, subtitle and actions over a gradient, in three heights.7 views · 7 downloadsFigureAn image (or anything) with a caption and a credit under it, as a figure with its figcaption.8 views · 9 downloadsImageAn image that keeps its shape while it loads, shimmering, and shows a fallback with its alt when it cannot.6 views · 9 downloadsImageGalleryA grid of thumbnails (the last can say "+N") opening a framed viewer with captions, next and previous, the keys and a strip.6 views · 7 downloads
Thread
Thread 0 posts
On ImageCrop: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.