Let image edit set exact pixel size with optional aspect lock.
office.sejong.dev/pipeline/head This commit looks good
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:
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';
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<RatioFit>('crop');
|
||||
const scalePercent = ref(100);
|
||||
const maxEdgeId = ref('');
|
||||
const lockAspect = ref(true);
|
||||
const outWidth = ref(1);
|
||||
const outHeight = ref(1);
|
||||
const format = ref<ExportFormat>('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(() => {
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<span class="field__row">
|
||||
배율
|
||||
<em>{{ scalePercent }}%</em>
|
||||
</span>
|
||||
<input
|
||||
v-model.number="scalePercent"
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
max="200"
|
||||
step="1"
|
||||
:value="scalePercent"
|
||||
@input="
|
||||
scalePercent = Number(
|
||||
($event.target as HTMLInputElement).value,
|
||||
);
|
||||
applyScaleToOutput();
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label class="field field--inline">
|
||||
@@ -716,6 +856,68 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
@@ -742,7 +944,10 @@ onBeforeUnmount(() => {
|
||||
.field select,
|
||||
.field input[type='range'],
|
||||
.field input[type='color'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* Drop the UA 2px margin so ranges align with the other controls. */
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user