From 545a2802e2a7abfb4b813e33ef026f6413581872 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Mon, 14 Sep 2026 13:47:53 +0900 Subject: [PATCH] Let image edit set exact pixel size with optional aspect lock. Keep width and height editable, and align the side panel so sliders and px fields no longer overflow the right edge. Co-authored-by: Cursor --- .../src/integrations/imageedit/process.ts | 47 +++- .../frontend/src/views/ToolsImageEditView.vue | 243 ++++++++++++++++-- 2 files changed, 259 insertions(+), 31 deletions(-) diff --git a/packages/frontend/src/integrations/imageedit/process.ts b/packages/frontend/src/integrations/imageedit/process.ts index 45a7a0e..5e6a25a 100644 --- a/packages/frontend/src/integrations/imageedit/process.ts +++ b/packages/frontend/src/integrations/imageedit/process.ts @@ -84,12 +84,18 @@ export function extensionForFormat(format: ExportFormat) { return 'png'; } +export function clampPixelSize(value: number) { + return Math.min(8192, Math.max(1, Math.round(Number(value)) || 1)); +} + function outputSize( srcWidth: number, srcHeight: number, scale: number, maxEdge: number | null, padAspect: number | null, + targetWidth?: number | null, + targetHeight?: number | null, ) { let width = Math.max(1, srcWidth); let height = Math.max(1, srcHeight); @@ -103,22 +109,29 @@ function outputSize( } } - const factor = Math.min(1, Math.max(0.01, scale)); - width *= factor; - height *= factor; + if ( + targetWidth != null && + targetHeight != null && + targetWidth > 0 && + targetHeight > 0 + ) { + width = targetWidth; + height = targetHeight; + } else { + const factor = Math.min(8, Math.max(0.01, scale)); + width *= factor; + height *= factor; + } if (maxEdge && maxEdge > 0) { - const long = Math.max(width, height); - if (long > maxEdge) { - const shrink = maxEdge / long; - width *= shrink; - height *= shrink; - } + const cap = Math.min(1, maxEdge / width, maxEdge / height); + width *= cap; + height *= cap; } return { - width: Math.max(1, Math.round(width)), - height: Math.max(1, Math.round(height)), + width: clampPixelSize(width), + height: clampPixelSize(height), }; } @@ -128,6 +141,8 @@ export function previewOutputSize(options: { maxEdge: number | null; padAspect: number | null; fit: RatioFit; + targetWidth?: number | null; + targetHeight?: number | null; }) { const padAspect = options.fit === 'pad' ? options.padAspect : null; return outputSize( @@ -136,6 +151,8 @@ export function previewOutputSize(options: { options.scale, options.maxEdge, padAspect, + options.targetWidth, + options.targetHeight, ); } @@ -195,6 +212,9 @@ export async function exportEditedImage(options: { fit: RatioFit; padAspect: number | null; padColor: string; + targetWidth?: number | null; + targetHeight?: number | null; + lockAspect?: boolean; }): Promise<{ blob: Blob; width: number; height: number }> { const crop = clampCrop( options.crop, @@ -211,6 +231,8 @@ export async function exportEditedImage(options: { options.scale, options.maxEdge, padAspect, + options.targetWidth, + options.targetHeight, ); const canvas = document.createElement('canvas'); @@ -230,7 +252,8 @@ export async function exportEditedImage(options: { ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - if (padAspect) { + const letterbox = Boolean(padAspect) && options.lockAspect !== false; + if (letterbox) { const fitted = containSize(crop.width, crop.height, size.width, size.height); const dx = Math.round((size.width - fitted.width) / 2); const dy = Math.round((size.height - fitted.height) / 2); diff --git a/packages/frontend/src/views/ToolsImageEditView.vue b/packages/frontend/src/views/ToolsImageEditView.vue index 5a2fab8..01ff86d 100644 --- a/packages/frontend/src/views/ToolsImageEditView.vue +++ b/packages/frontend/src/views/ToolsImageEditView.vue @@ -16,6 +16,7 @@ import { loadImageFromUrl, maxCropForAspect, previewOutputSize, + clampPixelSize, type ExportFormat, type PixelRect, type RatioFit, @@ -55,6 +56,9 @@ const ratioId = ref<(typeof RATIO_PRESETS)[number]['id']>('free'); const fit = ref('crop'); const scalePercent = ref(100); const maxEdgeId = ref(''); +const lockAspect = ref(true); +const outWidth = ref(1); +const outHeight = ref(1); const format = ref('image/jpeg'); const quality = ref(80); const padColor = ref('#ffffff'); @@ -85,6 +89,21 @@ const maxEdge = computed(() => { return n > 0 ? n : null; }); +const naturalSize = computed(() => + previewOutputSize({ + crop: crop.value, + scale: 1, + maxEdge: null, + padAspect: cropAspect.value, + fit: fit.value, + }), +); + +const sourceAspect = computed(() => { + const { width, height } = naturalSize.value; + return height ? width / height : 1; +}); + const outputHint = computed(() => previewOutputSize({ crop: crop.value, @@ -92,9 +111,68 @@ const outputHint = computed(() => maxEdge: maxEdge.value, padAspect: cropAspect.value, fit: fit.value, + targetWidth: outWidth.value, + targetHeight: outHeight.value, }), ); +function exportPayload() { + return { + crop: crop.value, + scale: scalePercent.value / 100, + maxEdge: maxEdge.value, + format: format.value, + quality: quality.value / 100, + fit: fit.value, + padAspect: cropAspect.value, + padColor: padColor.value, + targetWidth: outWidth.value, + targetHeight: outHeight.value, + lockAspect: lockAspect.value, + }; +} + +function syncScaleFromOutput() { + const base = naturalSize.value.width || 1; + const percent = (outWidth.value / base) * 100; + scalePercent.value = Math.min(200, Math.max(1, Math.round(percent))); +} + +function applyScaleToOutput() { + const size = previewOutputSize({ + crop: crop.value, + scale: scalePercent.value / 100, + maxEdge: maxEdge.value, + padAspect: cropAspect.value, + fit: fit.value, + }); + outWidth.value = size.width; + outHeight.value = size.height; +} + +function setOutWidth(raw: number) { + const width = clampPixelSize(raw); + outWidth.value = width; + if (lockAspect.value) { + outHeight.value = clampPixelSize(width / sourceAspect.value); + } + syncScaleFromOutput(); +} + +function setOutHeight(raw: number) { + const height = clampPixelSize(raw); + outHeight.value = height; + if (lockAspect.value) { + outWidth.value = clampPixelSize(height * sourceAspect.value); + } + syncScaleFromOutput(); +} + +function onLockAspect(next: boolean) { + lockAspect.value = next; + if (next) setOutWidth(outWidth.value); +} + const qualityDisabled = computed( () => format.value === 'image/png' || format.value === 'image/x-icon', ); @@ -156,7 +234,9 @@ async function loadFile(file: File) { fit.value = 'crop'; scalePercent.value = 100; maxEdgeId.value = ''; + lockAspect.value = true; crop.value = fullImageCrop(imageWidth.value, imageHeight.value); + applyScaleToOutput(); notice.value = `${imageWidth.value}×${imageHeight.value} · ${formatBytes(file.size)}${extra}`; } catch (reason) { if (url) revoke(url); @@ -256,6 +336,7 @@ function resetCrop() { } else { crop.value = fullImageCrop(imageWidth.value, imageHeight.value); } + applyScaleToOutput(); } watch(ratioId, () => { @@ -288,6 +369,16 @@ watch(fit, (next) => { } }); +watch(maxEdgeId, () => { + if (!hasImage.value) return; + applyScaleToOutput(); +}); + +watch([ratioId, fit], () => { + if (!hasImage.value) return; + if (lockAspect.value) applyScaleToOutput(); +}); + let estimateTimer = 0; let estimateGen = 0; @@ -302,6 +393,9 @@ watch( ratioId, padColor, loadedImage, + outWidth, + outHeight, + lockAspect, ], () => { window.clearTimeout(estimateTimer); @@ -324,14 +418,7 @@ async function refreshEstimate() { try { const result = await exportEditedImage({ image, - crop: crop.value, - scale: scalePercent.value / 100, - maxEdge: maxEdge.value, - format: format.value, - quality: quality.value / 100, - fit: fit.value, - padAspect: cropAspect.value, - padColor: padColor.value, + ...exportPayload(), }); if (gen !== estimateGen) return; estimateBytes.value = result.blob.size; @@ -351,14 +438,7 @@ async function saveImage() { try { const result = await exportEditedImage({ image, - crop: crop.value, - scale: scalePercent.value / 100, - maxEdge: maxEdge.value, - format: format.value, - quality: quality.value / 100, - fit: fit.value, - padAspect: cropAspect.value, - padColor: padColor.value, + ...exportPayload(), }); const ext = extensionForFormat(format.value); downloadBlob(result.blob, `${baseName()}-edited.${ext}`); @@ -498,18 +578,78 @@ onBeforeUnmount(() => {
-

축소

+

크기

+ +
+ + +