Let image edit set exact pixel size with optional aspect lock.
office.sejong.dev/pipeline/head This commit looks good

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 <cursoragent@cursor.com>
This commit is contained in:
ysj7705andCursor committed 2026-09-14 13:47:53 +09:00
1 parent eadbfb0f86
commit 545a2802e2
2 files changed
+259 -31

No files matched your search

@@ -84,12 +84,18 @@ export function extensionForFormat(format: ExportFormat) {
return 'png'; return 'png';
} }
export function clampPixelSize(value: number) {
return Math.min(8192, Math.max(1, Math.round(Number(value)) || 1));
}
function outputSize( function outputSize(
srcWidth: number, srcWidth: number,
srcHeight: number, srcHeight: number,
scale: number, scale: number,
maxEdge: number | null, maxEdge: number | null,
padAspect: number | null, padAspect: number | null,
targetWidth?: number | null,
targetHeight?: number | null,
) { ) {
let width = Math.max(1, srcWidth); let width = Math.max(1, srcWidth);
let height = Math.max(1, srcHeight); let height = Math.max(1, srcHeight);
@@ -103,22 +109,29 @@ function outputSize(
} }
} }
const factor = Math.min(1, Math.max(0.01, scale)); if (
width *= factor; targetWidth != null &&
height *= factor; 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) { if (maxEdge && maxEdge > 0) {
const long = Math.max(width, height); const cap = Math.min(1, maxEdge / width, maxEdge / height);
if (long > maxEdge) { width *= cap;
const shrink = maxEdge / long; height *= cap;
width *= shrink;
height *= shrink;
}
} }
return { return {
width: Math.max(1, Math.round(width)), width: clampPixelSize(width),
height: Math.max(1, Math.round(height)), height: clampPixelSize(height),
}; };
} }
@@ -128,6 +141,8 @@ export function previewOutputSize(options: {
maxEdge: number | null; maxEdge: number | null;
padAspect: number | null; padAspect: number | null;
fit: RatioFit; fit: RatioFit;
targetWidth?: number | null;
targetHeight?: number | null;
}) { }) {
const padAspect = options.fit === 'pad' ? options.padAspect : null; const padAspect = options.fit === 'pad' ? options.padAspect : null;
return outputSize( return outputSize(
@@ -136,6 +151,8 @@ export function previewOutputSize(options: {
options.scale, options.scale,
options.maxEdge, options.maxEdge,
padAspect, padAspect,
options.targetWidth,
options.targetHeight,
); );
} }
@@ -195,6 +212,9 @@ export async function exportEditedImage(options: {
fit: RatioFit; fit: RatioFit;
padAspect: number | null; padAspect: number | null;
padColor: string; padColor: string;
targetWidth?: number | null;
targetHeight?: number | null;
lockAspect?: boolean;
}): Promise<{ blob: Blob; width: number; height: number }> { }): Promise<{ blob: Blob; width: number; height: number }> {
const crop = clampCrop( const crop = clampCrop(
options.crop, options.crop,
@@ -211,6 +231,8 @@ export async function exportEditedImage(options: {
options.scale, options.scale,
options.maxEdge, options.maxEdge,
padAspect, padAspect,
options.targetWidth,
options.targetHeight,
); );
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -230,7 +252,8 @@ export async function exportEditedImage(options: {
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; 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 fitted = containSize(crop.width, crop.height, size.width, size.height);
const dx = Math.round((size.width - fitted.width) / 2); const dx = Math.round((size.width - fitted.width) / 2);
const dy = Math.round((size.height - fitted.height) / 2); const dy = Math.round((size.height - fitted.height) / 2);
@@ -16,6 +16,7 @@ import {
loadImageFromUrl, loadImageFromUrl,
maxCropForAspect, maxCropForAspect,
previewOutputSize, previewOutputSize,
clampPixelSize,
type ExportFormat, type ExportFormat,
type PixelRect, type PixelRect,
type RatioFit, type RatioFit,
@@ -55,6 +56,9 @@ const ratioId = ref<(typeof RATIO_PRESETS)[number]['id']>('free');
const fit = ref<RatioFit>('crop'); const fit = ref<RatioFit>('crop');
const scalePercent = ref(100); const scalePercent = ref(100);
const maxEdgeId = ref(''); const maxEdgeId = ref('');
const lockAspect = ref(true);
const outWidth = ref(1);
const outHeight = ref(1);
const format = ref<ExportFormat>('image/jpeg'); const format = ref<ExportFormat>('image/jpeg');
const quality = ref(80); const quality = ref(80);
const padColor = ref('#ffffff'); const padColor = ref('#ffffff');
@@ -85,6 +89,21 @@ const maxEdge = computed(() => {
return n > 0 ? n : null; 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(() => const outputHint = computed(() =>
previewOutputSize({ previewOutputSize({
crop: crop.value, crop: crop.value,
@@ -92,9 +111,68 @@ const outputHint = computed(() =>
maxEdge: maxEdge.value, maxEdge: maxEdge.value,
padAspect: cropAspect.value, padAspect: cropAspect.value,
fit: fit.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( const qualityDisabled = computed(
() => format.value === 'image/png' || format.value === 'image/x-icon', () => format.value === 'image/png' || format.value === 'image/x-icon',
); );
@@ -156,7 +234,9 @@ async function loadFile(file: File) {
fit.value = 'crop'; fit.value = 'crop';
scalePercent.value = 100; scalePercent.value = 100;
maxEdgeId.value = ''; maxEdgeId.value = '';
lockAspect.value = true;
crop.value = fullImageCrop(imageWidth.value, imageHeight.value); crop.value = fullImageCrop(imageWidth.value, imageHeight.value);
applyScaleToOutput();
notice.value = `${imageWidth.value}×${imageHeight.value} · ${formatBytes(file.size)}${extra}`; notice.value = `${imageWidth.value}×${imageHeight.value} · ${formatBytes(file.size)}${extra}`;
} catch (reason) { } catch (reason) {
if (url) revoke(url); if (url) revoke(url);
@@ -256,6 +336,7 @@ function resetCrop() {
} else { } else {
crop.value = fullImageCrop(imageWidth.value, imageHeight.value); crop.value = fullImageCrop(imageWidth.value, imageHeight.value);
} }
applyScaleToOutput();
} }
watch(ratioId, () => { 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 estimateTimer = 0;
let estimateGen = 0; let estimateGen = 0;
@@ -302,6 +393,9 @@ watch(
ratioId, ratioId,
padColor, padColor,
loadedImage, loadedImage,
outWidth,
outHeight,
lockAspect,
], ],
() => { () => {
window.clearTimeout(estimateTimer); window.clearTimeout(estimateTimer);
@@ -324,14 +418,7 @@ async function refreshEstimate() {
try { try {
const result = await exportEditedImage({ const result = await exportEditedImage({
image, image,
crop: crop.value, ...exportPayload(),
scale: scalePercent.value / 100,
maxEdge: maxEdge.value,
format: format.value,
quality: quality.value / 100,
fit: fit.value,
padAspect: cropAspect.value,
padColor: padColor.value,
}); });
if (gen !== estimateGen) return; if (gen !== estimateGen) return;
estimateBytes.value = result.blob.size; estimateBytes.value = result.blob.size;
@@ -351,14 +438,7 @@ async function saveImage() {
try { try {
const result = await exportEditedImage({ const result = await exportEditedImage({
image, image,
crop: crop.value, ...exportPayload(),
scale: scalePercent.value / 100,
maxEdge: maxEdge.value,
format: format.value,
quality: quality.value / 100,
fit: fit.value,
padAspect: cropAspect.value,
padColor: padColor.value,
}); });
const ext = extensionForFormat(format.value); const ext = extensionForFormat(format.value);
downloadBlob(result.blob, `${baseName()}-edited.${ext}`); downloadBlob(result.blob, `${baseName()}-edited.${ext}`);
@@ -498,18 +578,78 @@ onBeforeUnmount(() => {
</section> </section>
<section class="group"> <section class="group">
<h2>축소</h2> <h2>크기</h2>
<label class="settings-check">
<input
type="checkbox"
:checked="lockAspect"
@change="
onLockAspect(
($event.target as HTMLInputElement).checked,
)
"
/>
비율 유지
</label>
<div class="size-row">
<label class="size-field">
<span class="size-field__name">가로</span>
<span class="size-input">
<input
type="number"
min="1"
max="8192"
step="1"
inputmode="numeric"
:value="outWidth"
aria-label="가로 픽셀"
@change="
setOutWidth(
Number(($event.target as HTMLInputElement).value),
)
"
/>
<em>px</em>
</span>
</label>
<label class="size-field">
<span class="size-field__name">세로</span>
<span class="size-input">
<input
type="number"
min="1"
max="8192"
step="1"
inputmode="numeric"
:value="outHeight"
aria-label="세로 픽셀"
@change="
setOutHeight(
Number(($event.target as HTMLInputElement).value),
)
"
/>
<em>px</em>
</span>
</label>
</div>
<label class="field"> <label class="field">
<span class="field__row"> <span class="field__row">
배율 배율
<em>{{ scalePercent }}%</em> <em>{{ scalePercent }}%</em>
</span> </span>
<input <input
v-model.number="scalePercent"
type="range" type="range"
min="10" min="10"
max="100" max="200"
step="1" step="1"
:value="scalePercent"
@input="
scalePercent = Number(
($event.target as HTMLInputElement).value,
);
applyScaleToOutput();
"
/> />
</label> </label>
<label class="field field--inline"> <label class="field field--inline">
@@ -716,6 +856,68 @@ onBeforeUnmount(() => {
font-weight: 650; font-weight: 650;
} }
.settings-check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.86rem;
}
.size-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
.size-field {
display: grid;
gap: 0.25rem;
min-width: 0;
font-size: 0.8rem;
}
.size-field__name {
color: var(--muted);
}
.size-input {
display: flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
min-height: 2.25rem;
padding: 0 0.45rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--canvas);
}
.size-input:focus-within {
border-color: var(--accent);
}
.size-input input {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
background: none;
color: var(--ink);
font: inherit;
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.size-input input:focus {
outline: none;
}
.size-input em {
font-style: normal;
font-size: 0.75rem;
color: var(--muted);
}
.field { .field {
display: grid; display: grid;
gap: 0.3rem; gap: 0.3rem;
@@ -742,7 +944,10 @@ onBeforeUnmount(() => {
.field select, .field select,
.field input[type='range'], .field input[type='range'],
.field input[type='color'] { .field input[type='color'] {
display: block;
width: 100%; width: 100%;
/* Drop the UA 2px margin so ranges align with the other controls. */
margin: 0;
accent-color: var(--accent); accent-color: var(--accent);
} }