From 3a00338392d6049c108cb90768f6ca9717ec640a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:49:00 +0000 Subject: [PATCH] ForgeImage v1.1: live output previews, resize sliders, visual drag crop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview stage now shows the OUTPUT of the active operation, not just the source: resize renders the resized result live (with the preview scale noted), convert renders the actual encoded bytes with the real file size (debounced re-encode on the quality slider), and crop overlays a draggable selection on the image itself — corner handles resize (optionally constrained to 1:1/4:3/16:9/9:16 via the select, which now defaults to Free), the middle moves it, outside is darkened, and the selection size/position reads out live. Resize gains width and height sliders synced two-way with the number inputs (range spans up to 2x the natural size), with aspect lock driving the other axis. The drag geometry is pure and unit-tested (dragCropRect with anchored corners, aspect constraint, min size, and image-bounds clamping; hitCropHandle; fitWithin) — 11 new tests, 30 total for the tool. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S3TawWUgFWTaTAcrLyEEAa --- .../frontend/src/__tests__/engine.test.ts | 68 +++++ tools/forgeimage/frontend/src/app.ts | 269 ++++++++++++++++-- tools/forgeimage/frontend/src/engine.ts | 96 +++++++ tools/forgeimage/frontend/src/index.html | 35 ++- tools/forgeimage/frontend/src/styles.css | 16 ++ 5 files changed, 450 insertions(+), 34 deletions(-) diff --git a/tools/forgeimage/frontend/src/__tests__/engine.test.ts b/tools/forgeimage/frontend/src/__tests__/engine.test.ts index f2afee4..d5944a4 100644 --- a/tools/forgeimage/frontend/src/__tests__/engine.test.ts +++ b/tools/forgeimage/frontend/src/__tests__/engine.test.ts @@ -120,3 +120,71 @@ describe("fmtBytes", () => { expect(fmtBytes(3 * 1024 * 1024)).toBe("3.00 MB"); }); }); + +import { fitWithin, dragCropRect, hitCropHandle } from "../engine"; + +describe("fitWithin", () => { + it("scales down to fit, preserving aspect", () => { + const f = fitWithin({ w: 2000, h: 1000 }, 500, 500); + expect(f).toEqual({ w: 500, h: 250, scale: 0.25 }); + }); + it("never upscales", () => { + expect(fitWithin({ w: 100, h: 80 }, 500, 500).scale).toBe(1); + }); +}); + +describe("dragCropRect", () => { + const img = { w: 1000, h: 800 }; + const rect = { x: 200, y: 200, w: 400, h: 300 }; + + it("move translates and clamps", () => { + expect(dragCropRect(rect, "move", 50, -20, img)).toEqual({ x: 250, y: 180, w: 400, h: 300 }); + const clamped = dragCropRect(rect, "move", 9999, 9999, img); + expect(clamped.x + clamped.w).toBe(img.w); + expect(clamped.y + clamped.h).toBe(img.h); + }); + + it("se drag grows from the nw anchor", () => { + const r = dragCropRect(rect, "se", 100, 50, img); + expect(r).toEqual({ x: 200, y: 200, w: 500, h: 350 }); + }); + + it("nw drag keeps the se corner fixed", () => { + const r = dragCropRect(rect, "nw", -50, -50, img); + expect(r.x + r.w).toBe(600); + expect(r.y + r.h).toBe(500); + expect(r.w).toBe(450); + }); + + it("enforces a minimum size when collapsed", () => { + const r = dragCropRect(rect, "se", -1000, -1000, img); + expect(r.w).toBeGreaterThanOrEqual(16); + expect(r.h).toBeGreaterThanOrEqual(16); + }); + + it("respects an aspect constraint", () => { + const r = dragCropRect(rect, "se", 200, 0, img, { w: 1, h: 1 }); + expect(Math.abs(r.w - r.h)).toBeLessThanOrEqual(1); + }); + + it("never escapes the image, even with aspect", () => { + const r = dragCropRect(rect, "se", 5000, 5000, img, { w: 16, h: 9 }); + expect(r.x + r.w).toBeLessThanOrEqual(img.w); + expect(r.y + r.h).toBeLessThanOrEqual(img.h); + expect(Math.abs(r.w / r.h - 16 / 9)).toBeLessThan(0.05); + }); +}); + +describe("hitCropHandle", () => { + const rect = { x: 100, y: 100, w: 200, h: 150 }; + it("detects corners within tolerance", () => { + expect(hitCropHandle(102, 98, rect, 8)).toBe("nw"); + expect(hitCropHandle(298, 252, rect, 8)).toBe("se"); + expect(hitCropHandle(300, 100, rect, 8)).toBe("ne"); + expect(hitCropHandle(100, 250, rect, 8)).toBe("sw"); + }); + it("inside → move, outside → null", () => { + expect(hitCropHandle(200, 175, rect, 8)).toBe("move"); + expect(hitCropHandle(50, 50, rect, 8)).toBeNull(); + }); +}); diff --git a/tools/forgeimage/frontend/src/app.ts b/tools/forgeimage/frontend/src/app.ts index 00c941f..41eff17 100644 --- a/tools/forgeimage/frontend/src/app.ts +++ b/tools/forgeimage/frontend/src/app.ts @@ -1,11 +1,19 @@ -/* ForgeImage — Canvas wiring around engine.ts. */ +/* ForgeImage — Canvas wiring around engine.ts. + A shared preview stage shows the live OUTPUT of the active operation: + resize renders the resized result, crop overlays a draggable rect on + the source, convert renders the actual encoded bytes. */ import { resizeDims, centeredAspectCrop, coverCropForPreset, targetSizeQuality, + fitWithin, + dragCropRect, + hitCropHandle, fmtBytes, + CropRect, + CropHandle, ASPECT_PRESETS, FORMATS, SOCIAL_PRESETS, @@ -17,18 +25,27 @@ const $ = (id: string): T => { return el as T; }; +const STAGE_MAX_W = 600; +const STAGE_MAX_H = 420; + let img: HTMLImageElement | null = null; let srcName = "image"; +let activeOp = "resize"; +let cropRect: CropRect | null = null; +let dragging: { handle: CropHandle; lastX: number; lastY: number } | null = null; +let convertTimer = 0; const status = (msg: string, isError = false): void => { const el = $("op-status"); el.textContent = msg; el.classList.toggle("is-error", isError); }; - const clearDownloads = (): void => { $("op-downloads").innerHTML = ""; }; +const caption = (msg: string): void => { + $("stage-caption").textContent = msg; +}; function toBlob(canvas: HTMLCanvasElement, mime: string, quality?: number): Promise { return new Promise((resolve, reject) => @@ -39,11 +56,11 @@ function toBlob(canvas: HTMLCanvasElement, mime: string, quality?: number): Prom function draw(sx: number, sy: number, sw: number, sh: number, dw: number, dh: number): HTMLCanvasElement { if (!img) throw new Error("Pick an image first"); const c = document.createElement("canvas"); - c.width = dw; - c.height = dh; + c.width = Math.max(1, Math.round(dw)); + c.height = Math.max(1, Math.round(dh)); const ctx = c.getContext("2d"); if (!ctx) throw new Error("Canvas unavailable"); - ctx.drawImage(img, sx, sy, sw, sh, 0, 0, dw, dh); + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, c.width, c.height); return c; } @@ -69,6 +86,185 @@ async function runOp(label: string, fn: () => Promise): Promise { } } +/* ── resize state ── */ +function readResizeDims(): { w: number; h: number } { + const w = Number($("rs-width").value) || img?.naturalWidth || 1; + const h = Number($("rs-height").value) || img?.naturalHeight || 1; + return { w: Math.max(1, w), h: Math.max(1, h) }; +} + +function setResize(w: number, h: number): void { + $("rs-width").value = String(Math.round(w)); + $("rs-height").value = String(Math.round(h)); + $("rs-width-slider").value = String(Math.round(w)); + $("rs-height-slider").value = String(Math.round(h)); + $("rs-width-out").textContent = String(Math.round(w)); + $("rs-height-out").textContent = String(Math.round(h)); +} + +function onResizeAxis(axis: "width" | "height", value: number): void { + if (!img) return; + const lock = $("rs-lock").checked; + const cur = readResizeDims(); + if (axis === "width") { + const d = lock ? resizeDims({ w: img.naturalWidth, h: img.naturalHeight }, { width: value }) : { w: value, h: cur.h }; + setResize(d.w, d.h); + } else { + const d = lock ? resizeDims({ w: img.naturalWidth, h: img.naturalHeight }, { height: value }) : { w: cur.w, h: value }; + setResize(d.w, d.h); + } + renderStage(); +} + +/* ── stage rendering ── */ +function stageCtx(): { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D } { + const c = $("stage"); + const ctx = c.getContext("2d"); + if (!ctx) throw new Error("Canvas unavailable"); + return { c, ctx }; +} + +function renderStage(): void { + const empty = $("stage-empty"); + const { c, ctx } = stageCtx(); + if (!img) { + c.classList.remove("is-visible"); + empty.hidden = false; + caption(""); + return; + } + empty.hidden = true; + c.classList.add("is-visible"); + c.classList.toggle("is-cropping", activeOp === "crop"); + + if (activeOp === "resize") { + const target = readResizeDims(); + const fit = fitWithin(target, STAGE_MAX_W, STAGE_MAX_H); + c.width = fit.w; + c.height = fit.h; + ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, fit.w, fit.h); + caption(`Output: ${target.w} × ${target.h} px${fit.scale < 1 ? ` (preview at ${(fit.scale * 100).toFixed(0)}%)` : ""}`); + return; + } + + // Default: source image fitted to the stage (crop adds its overlay). + const fit = fitWithin({ w: img.naturalWidth, h: img.naturalHeight }, STAGE_MAX_W, STAGE_MAX_H); + c.width = fit.w; + c.height = fit.h; + ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, fit.w, fit.h); + + if (activeOp === "crop" && cropRect) { + const s = fit.scale; + const r = { x: cropRect.x * s, y: cropRect.y * s, w: cropRect.w * s, h: cropRect.h * s }; + ctx.fillStyle = "rgba(0,0,0,0.55)"; + ctx.fillRect(0, 0, c.width, r.y); + ctx.fillRect(0, r.y + r.h, c.width, c.height - r.y - r.h); + ctx.fillRect(0, r.y, r.x, r.h); + ctx.fillRect(r.x + r.w, r.y, c.width - r.x - r.w, r.h); + ctx.strokeStyle = "#58c470"; + ctx.lineWidth = 2; + ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w, r.h); + ctx.fillStyle = "#58c470"; + for (const [hx, hy] of [[r.x, r.y], [r.x + r.w, r.y], [r.x, r.y + r.h], [r.x + r.w, r.y + r.h]]) { + ctx.fillRect(hx - 5, hy - 5, 10, 10); + } + caption(""); + $("cr-meta").textContent = `Selection: ${Math.round(cropRect.w)} × ${Math.round(cropRect.h)} px at (${Math.round(cropRect.x)}, ${Math.round(cropRect.y)})`; + return; + } + + caption(`${img.naturalWidth} × ${img.naturalHeight} px`); + if (activeOp === "convert") scheduleConvertPreview(); +} + +/** Convert tab: render the ACTUAL encoded output into the stage (debounced). */ +function scheduleConvertPreview(): void { + window.clearTimeout(convertTimer); + convertTimer = window.setTimeout(async () => { + if (!img || activeOp !== "convert") return; + try { + const f = FORMATS.find((x) => x.id === $("cv-format").value)!; + const q = Number($("cv-quality").value) / 100; + const full = draw(0, 0, img.naturalWidth, img.naturalHeight, img.naturalWidth, img.naturalHeight); + const blob = await toBlob(full, f.mime, f.lossy ? q : undefined); + const url = URL.createObjectURL(blob); + const preview = new Image(); + preview.onload = () => { + if (activeOp !== "convert") return; + const { c, ctx } = stageCtx(); + const fit = fitWithin({ w: preview.naturalWidth, h: preview.naturalHeight }, STAGE_MAX_W, STAGE_MAX_H); + c.width = fit.w; + c.height = fit.h; + ctx.drawImage(preview, 0, 0, fit.w, fit.h); + caption(`${f.label}${f.lossy ? ` @ ${Math.round(q * 100)}%` : ""} → ${fmtBytes(blob.size)}`); + URL.revokeObjectURL(url); + }; + preview.src = url; + } catch { + /* stage keeps last frame */ + } + }, 180); +} + +/* ── crop pointer interaction ── */ +function stagePoint(e: PointerEvent): { x: number; y: number; scale: number } { + const c = $("stage"); + const r = c.getBoundingClientRect(); + const fit = fitWithin({ w: img!.naturalWidth, h: img!.naturalHeight }, STAGE_MAX_W, STAGE_MAX_H); + const x = ((e.clientX - r.left) / r.width) * c.width; + const y = ((e.clientY - r.top) / r.height) * c.height; + return { x, y, scale: fit.scale }; +} + +function currentAspect(): { w: number; h: number } | null { + const id = $("cr-aspect").value; + const a = ASPECT_PRESETS.find((x) => x.id === id); + return a ? { w: a.w, h: a.h } : null; +} + +function initCropPointer(): void { + const c = $("stage"); + c.addEventListener("pointerdown", (e) => { + if (activeOp !== "crop" || !img || !cropRect) return; + const p = stagePoint(e); + const dispRect = { + x: cropRect.x * p.scale, y: cropRect.y * p.scale, + w: cropRect.w * p.scale, h: cropRect.h * p.scale, + }; + const handle = hitCropHandle(p.x, p.y, dispRect, 12); + if (!handle) return; + dragging = { handle, lastX: p.x, lastY: p.y }; + c.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + c.addEventListener("pointermove", (e) => { + if (!dragging || !img || !cropRect) return; + const p = stagePoint(e); + const dx = (p.x - dragging.lastX) / p.scale; + const dy = (p.y - dragging.lastY) / p.scale; + dragging.lastX = p.x; + dragging.lastY = p.y; + cropRect = dragCropRect(cropRect, dragging.handle, dx, dy, + { w: img.naturalWidth, h: img.naturalHeight }, currentAspect()); + renderStage(); + }); + const stop = (): void => { + dragging = null; + }; + c.addEventListener("pointerup", stop); + c.addEventListener("pointercancel", stop); +} + +function resetCropRect(): void { + if (!img) return; + const dims = { w: img.naturalWidth, h: img.naturalHeight }; + const a = currentAspect(); + cropRect = a + ? centeredAspectCrop(dims, a.w, a.h) + : { x: Math.round(dims.w * 0.1), y: Math.round(dims.h * 0.1), w: Math.round(dims.w * 0.8), h: Math.round(dims.h * 0.8) }; +} + +/* ── tabs + wiring ── */ function initTabs(): void { const tabs = document.querySelectorAll(".op-tab"); tabs.forEach((tab) => @@ -80,18 +276,22 @@ function initTabs(): void { document.querySelectorAll(".op-panel").forEach((p) => { p.hidden = p.id !== `panel-${tab.dataset.op}`; }); + activeOp = tab.dataset.op ?? "resize"; + if (activeOp === "crop" && !cropRect) resetCropRect(); status(""); clearDownloads(); + renderStage(); }), ); } function init(): void { initTabs(); + initCropPointer(); - $("cr-aspect").innerHTML = ASPECT_PRESETS.map( - (a) => ``, - ).join(""); + $("cr-aspect").innerHTML = + `` + + ASPECT_PRESETS.map((a) => ``).join(""); $("cv-format").innerHTML = FORMATS.map( (f) => ``, ).join(""); @@ -108,46 +308,67 @@ function init(): void { el.onload = () => { img = el; $("img-meta").textContent = `${f.name}: ${el.naturalWidth}×${el.naturalHeight}, ${fmtBytes(f.size)}`; + // seed resize controls: sliders span 1..2x natural size + for (const [slider, max] of [ + ["rs-width-slider", el.naturalWidth * 2], + ["rs-height-slider", el.naturalHeight * 2], + ] as const) { + $(slider).max = String(Math.max(16, Math.round(max))); + } + setResize(el.naturalWidth, el.naturalHeight); + resetCropRect(); + renderStage(); }; el.onerror = () => status("Could not read that image.", true); el.src = url; }); + // resize: slider ↔ number, live preview + $("rs-width-slider").addEventListener("input", () => + onResizeAxis("width", Number($("rs-width-slider").value))); + $("rs-height-slider").addEventListener("input", () => + onResizeAxis("height", Number($("rs-height-slider").value))); + $("rs-width").addEventListener("input", () => + onResizeAxis("width", Number($("rs-width").value) || 1)); + $("rs-height").addEventListener("input", () => + onResizeAxis("height", Number($("rs-height").value) || 1)); + $("rs-lock").addEventListener("change", () => { + if ($("rs-lock").checked && img) { + onResizeAxis("width", readResizeDims().w); + } + }); + $("rs-run").addEventListener("click", () => runOp("Resizing", async () => { if (!img) throw new Error("Pick an image first"); - const spec = { - width: Number($("rs-width").value) || undefined, - height: Number($("rs-height").value) || undefined, - percent: Number($("rs-percent").value) || undefined, - lock: $("rs-lock").checked, - }; - if (spec.width === undefined && spec.height === undefined && spec.percent === undefined) - throw new Error("Enter a width, height, or percent"); - const d = resizeDims({ w: img.naturalWidth, h: img.naturalHeight }, spec); + const d = readResizeDims(); const c = draw(0, 0, img.naturalWidth, img.naturalHeight, d.w, d.h); offerBlob(await toBlob(c, "image/png"), `${baseName()}-${d.w}x${d.h}.png`); }), ); + $("cr-aspect").addEventListener("change", () => { + resetCropRect(); + renderStage(); + }); $("cr-run").addEventListener("click", () => runOp("Cropping", async () => { - if (!img) throw new Error("Pick an image first"); - const a = ASPECT_PRESETS.find((x) => x.id === $("cr-aspect").value)!; - const r = centeredAspectCrop({ w: img.naturalWidth, h: img.naturalHeight }, a.w, a.h); + if (!img || !cropRect) throw new Error("Pick an image first"); + const r = cropRect; const c = draw(r.x, r.y, r.w, r.h, r.w, r.h); - offerBlob(await toBlob(c, "image/png"), `${baseName()}-crop-${a.label.replace(":", "x")}.png`); + offerBlob(await toBlob(c, "image/png"), `${baseName()}-crop-${Math.round(r.w)}x${Math.round(r.h)}.png`); }), ); + $("cv-format").addEventListener("change", () => renderStage()); + $("cv-quality").addEventListener("input", () => scheduleConvertPreview()); $("cv-run").addEventListener("click", () => runOp("Converting", async () => { if (!img) throw new Error("Pick an image first"); const f = FORMATS.find((x) => x.id === $("cv-format").value)!; const q = Number($("cv-quality").value) / 100; const c = draw(0, 0, img.naturalWidth, img.naturalHeight, img.naturalWidth, img.naturalHeight); - const blob = await toBlob(c, f.mime, f.lossy ? q : undefined); - offerBlob(blob, `${baseName()}.${f.ext}`); + offerBlob(await toBlob(c, f.mime, f.lossy ? q : undefined), `${baseName()}.${f.ext}`); }), ); @@ -174,6 +395,8 @@ function init(): void { } }), ); + + renderStage(); } if (document.readyState === "loading") { diff --git a/tools/forgeimage/frontend/src/engine.ts b/tools/forgeimage/frontend/src/engine.ts index 6343243..659623a 100644 --- a/tools/forgeimage/frontend/src/engine.ts +++ b/tools/forgeimage/frontend/src/engine.ts @@ -135,3 +135,99 @@ export function fmtBytes(n: number): string { if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`; } + +/* ── interactive crop + preview geometry ── */ + +/** Scale dims to fit inside a box, never upscaling. */ +export function fitWithin(src: Dims, maxW: number, maxH: number): Dims & { scale: number } { + const scale = Math.min(maxW / src.w, maxH / src.h, 1); + return { w: Math.max(1, Math.round(src.w * scale)), h: Math.max(1, Math.round(src.h * scale)), scale }; +} + +export type CropHandle = "nw" | "ne" | "sw" | "se" | "move"; + +/** + * Apply a pointer drag (in IMAGE pixels) to a crop rect. Corner handles + * resize (respecting an optional aspect w/h constraint, min 16px); "move" + * translates. Result is clamped inside the image. + */ +export function dragCropRect( + rect: CropRect, + handle: CropHandle, + dx: number, + dy: number, + img: Dims, + aspect: { w: number; h: number } | null = null, +): CropRect { + const MIN = 16; + if (handle === "move") { + return clampCrop({ ...rect, x: rect.x + dx, y: rect.y + dy }, img); + } + // Anchor is the corner opposite the handle; the dragged corner follows the pointer. + const left = rect.x; + const top = rect.y; + const rightEdge = rect.x + rect.w; + const bottom = rect.y + rect.h; + const anchor = { + nw: { x: rightEdge, y: bottom }, + ne: { x: left, y: bottom }, + sw: { x: rightEdge, y: top }, + se: { x: left, y: top }, + }[handle]; + const corner = { + nw: { x: left + dx, y: top + dy }, + ne: { x: rightEdge + dx, y: top + dy }, + sw: { x: left + dx, y: bottom + dy }, + se: { x: rightEdge + dx, y: bottom + dy }, + }[handle]; + + let w = Math.max(MIN, Math.abs(corner.x - anchor.x)); + let h = Math.max(MIN, Math.abs(corner.y - anchor.y)); + if (aspect) { + const target = aspect.w / aspect.h; + // Follow the dominant axis of the drag, derive the other. + if (w / h > target) h = w / target; + else w = h * target; + } + // Rebuild the rect from the anchor toward the dragged corner's direction. + const dirX = corner.x >= anchor.x ? 1 : -1; + const dirY = corner.y >= anchor.y ? 1 : -1; + let x = dirX === 1 ? anchor.x : anchor.x - w; + let y = dirY === 1 ? anchor.y : anchor.y - h; + // Clamp size so the anchored corner stays fixed inside the image. + const maxW2 = dirX === 1 ? img.w - anchor.x : anchor.x; + const maxH2 = dirY === 1 ? img.h - anchor.y : anchor.y; + let scaleBack = Math.min(1, maxW2 / w, maxH2 / h); + if (!aspect) { + w = Math.min(w, maxW2); + h = Math.min(h, maxH2); + } else { + w *= scaleBack; + h *= scaleBack; + } + w = Math.max(MIN, w); + h = Math.max(MIN, h); + x = dirX === 1 ? anchor.x : anchor.x - w; + y = dirY === 1 ? anchor.y : anchor.y - h; + return clampCrop({ x, y, w, h }, img); +} + +/** Which handle (if any) a display-space point hits; tolerance in px. */ +export function hitCropHandle( + px: number, + py: number, + rect: CropRect, + tol: number, +): CropHandle | null { + const corners: Array<[CropHandle, number, number]> = [ + ["nw", rect.x, rect.y], + ["ne", rect.x + rect.w, rect.y], + ["sw", rect.x, rect.y + rect.h], + ["se", rect.x + rect.w, rect.y + rect.h], + ]; + for (const [h, cx, cy] of corners) { + if (Math.abs(px - cx) <= tol && Math.abs(py - cy) <= tol) return h; + } + if (px >= rect.x && px <= rect.x + rect.w && py >= rect.y && py <= rect.y + rect.h) return "move"; + return null; +} diff --git a/tools/forgeimage/frontend/src/index.html b/tools/forgeimage/frontend/src/index.html index 5f505b7..210c7ed 100644 --- a/tools/forgeimage/frontend/src/index.html +++ b/tools/forgeimage/frontend/src/index.html @@ -68,6 +68,12 @@

ForgeImage: Resize, crop, convert & compress — in your browser

+
+ +

Pick an image to see a live preview here.

+

+
+
-
- - - +
+ Width px +
+ + +
+
+
+ Height px +
+ + +
- +