Add a browser image editor and split scanner Open from Add files.
office.sejong.dev/pipeline/head This commit looks good
office.sejong.dev/pipeline/head This commit looks good
Crop, shrink, compress, and change aspect locally, and prompt for warp before showing an uncorrected preview. New pages default to auto paper ratio. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
194eb318d1
commit
26c98fe5e8
8 files changed
+1651
-13
No files matched your search
@@ -28,7 +28,8 @@ const viewKey = computed(() => {
|
||||
route.name === 'illustrator-viewer' ||
|
||||
route.name === 'photoshop-viewer' ||
|
||||
route.name === 'tools-scanner' ||
|
||||
route.name === 'tools-bg-remove'
|
||||
route.name === 'tools-bg-remove' ||
|
||||
route.name === 'tools-image-edit'
|
||||
) {
|
||||
return String(route.name);
|
||||
}
|
||||
@@ -50,6 +51,7 @@ const viewKey = computed(() => {
|
||||
'PhotoshopViewerView',
|
||||
'ToolsScannerView',
|
||||
'ToolsBgRemoveView',
|
||||
'ToolsImageEditView',
|
||||
]"
|
||||
:max="10"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
clampCrop,
|
||||
type PixelRect,
|
||||
} from '@/integrations/imageedit/process';
|
||||
|
||||
defineOptions({ name: 'CropEditor' });
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
crop: PixelRect;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
aspect: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:crop': [PixelRect];
|
||||
}>();
|
||||
|
||||
type Handle = 'move' | 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
const handles = computed<Handle[]>(() =>
|
||||
props.aspect
|
||||
? ['nw', 'ne', 'se', 'sw']
|
||||
: ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'],
|
||||
);
|
||||
|
||||
const boxStyle = computed(() => {
|
||||
const w = props.imageWidth || 1;
|
||||
const h = props.imageHeight || 1;
|
||||
return {
|
||||
left: `${(props.crop.x / w) * 100}%`,
|
||||
top: `${(props.crop.y / h) * 100}%`,
|
||||
width: `${(props.crop.width / w) * 100}%`,
|
||||
height: `${(props.crop.height / h) * 100}%`,
|
||||
};
|
||||
});
|
||||
|
||||
let drag: {
|
||||
handle: Handle;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
start: PixelRect;
|
||||
displayScale: number;
|
||||
} | null = null;
|
||||
|
||||
function displayScaleFromEvent(event: PointerEvent) {
|
||||
const stage = (event.currentTarget as HTMLElement).closest('.crop');
|
||||
const img = stage?.querySelector('img');
|
||||
if (!img || !props.imageWidth) return 1;
|
||||
return img.clientWidth / props.imageWidth;
|
||||
}
|
||||
|
||||
function bindWindow(on: boolean) {
|
||||
if (on) {
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', endDrag);
|
||||
window.addEventListener('pointercancel', endDrag);
|
||||
} else {
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', endDrag);
|
||||
window.removeEventListener('pointercancel', endDrag);
|
||||
}
|
||||
}
|
||||
|
||||
function beginDrag(handle: Handle, event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
bindWindow(false);
|
||||
drag = {
|
||||
handle,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
start: { ...props.crop },
|
||||
displayScale: displayScaleFromEvent(event),
|
||||
};
|
||||
bindWindow(true);
|
||||
}
|
||||
|
||||
function applyAspectFromFixedCorner(
|
||||
next: PixelRect,
|
||||
fixed: { x: number; y: number },
|
||||
preferWidth: boolean,
|
||||
) {
|
||||
const aspect = props.aspect;
|
||||
if (!aspect) return next;
|
||||
if (preferWidth) {
|
||||
next.height = next.width / aspect;
|
||||
} else {
|
||||
next.width = next.height * aspect;
|
||||
}
|
||||
next.x = fixed.x < next.x + next.width ? fixed.x : fixed.x - next.width;
|
||||
next.y = fixed.y < next.y + next.height ? fixed.y : fixed.y - next.height;
|
||||
if (next.width < 0) {
|
||||
next.x += next.width;
|
||||
next.width = Math.abs(next.width);
|
||||
}
|
||||
if (next.height < 0) {
|
||||
next.y += next.height;
|
||||
next.height = Math.abs(next.height);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
const scale = drag.displayScale || 1;
|
||||
const dx = (event.clientX - drag.startX) / scale;
|
||||
const dy = (event.clientY - drag.startY) / scale;
|
||||
const start = drag.start;
|
||||
let next: PixelRect = { ...start };
|
||||
const handle = drag.handle;
|
||||
const aspect = props.aspect;
|
||||
|
||||
if (handle === 'move') {
|
||||
next.x = start.x + dx;
|
||||
next.y = start.y + dy;
|
||||
emit(
|
||||
'update:crop',
|
||||
clampCrop(next, props.imageWidth, props.imageHeight),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const se = { x: start.x + start.width, y: start.y + start.height };
|
||||
const nw = { x: start.x, y: start.y };
|
||||
|
||||
if (handle.includes('e') || handle === 'e') next.width = start.width + dx;
|
||||
if (handle.includes('s') || handle === 's') next.height = start.height + dy;
|
||||
if (handle.includes('w') || handle === 'w') {
|
||||
next.x = start.x + dx;
|
||||
next.width = start.width - dx;
|
||||
}
|
||||
if (handle.includes('n') || handle === 'n') {
|
||||
next.y = start.y + dy;
|
||||
next.height = start.height - dy;
|
||||
}
|
||||
|
||||
if (aspect) {
|
||||
if (handle === 'se') {
|
||||
next = applyAspectFromFixedCorner(next, nw, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = nw.x;
|
||||
next.y = nw.y;
|
||||
} else if (handle === 'nw') {
|
||||
next = applyAspectFromFixedCorner(next, se, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = se.x - next.width;
|
||||
next.y = se.y - next.height;
|
||||
} else if (handle === 'ne') {
|
||||
const fixed = { x: start.x, y: se.y };
|
||||
next = applyAspectFromFixedCorner(next, fixed, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = start.x;
|
||||
next.y = se.y - next.height;
|
||||
} else if (handle === 'sw') {
|
||||
const fixed = { x: se.x, y: start.y };
|
||||
next = applyAspectFromFixedCorner(next, fixed, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = se.x - next.width;
|
||||
next.y = start.y;
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:crop', clampCrop(next, props.imageWidth, props.imageHeight));
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
drag = null;
|
||||
bindWindow(false);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => bindWindow(false));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crop">
|
||||
<img :src="src" alt="편집할 이미지" draggable="false" />
|
||||
<div class="crop__shade" aria-hidden="true" />
|
||||
<div
|
||||
class="crop__box"
|
||||
:style="boxStyle"
|
||||
@pointerdown="beginDrag('move', $event)"
|
||||
>
|
||||
<span
|
||||
v-for="handle in handles"
|
||||
:key="handle"
|
||||
class="crop__handle"
|
||||
:class="`crop__handle--${handle}`"
|
||||
@pointerdown="beginDrag(handle, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crop {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.crop img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: min(70vh, 44rem);
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.crop__shade {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.crop__box {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 0 0 9999px rgb(0 0 0 / 42%);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.crop__handle {
|
||||
position: absolute;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 1px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.crop__handle--nw {
|
||||
left: -0.4rem;
|
||||
top: -0.4rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.crop__handle--ne {
|
||||
right: -0.4rem;
|
||||
top: -0.4rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.crop__handle--se {
|
||||
right: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.crop__handle--sw {
|
||||
left: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.crop__handle--n {
|
||||
left: 50%;
|
||||
top: -0.4rem;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.crop__handle--s {
|
||||
left: 50%;
|
||||
bottom: -0.4rem;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.crop__handle--e {
|
||||
right: -0.4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.crop__handle--w {
|
||||
left: -0.4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ export type FaviconKind =
|
||||
| 'photoshop'
|
||||
| 'scanner'
|
||||
| 'bgremove'
|
||||
| 'imgedit'
|
||||
| 'text'
|
||||
| 'csv'
|
||||
| 'file';
|
||||
@@ -51,6 +52,9 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
|
||||
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
|
||||
bgremove:
|
||||
'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z',
|
||||
/** mdi:crop */
|
||||
imgedit:
|
||||
'M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17H19V7H7V17M17,17H7V7H17V17Z',
|
||||
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
|
||||
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||
@@ -91,6 +95,7 @@ export const ICON_COLORS: Record<FaviconKind, string> = {
|
||||
photoshop: '#31a8ff',
|
||||
scanner: '#00897b',
|
||||
bgremove: '#5c6bc0',
|
||||
imgedit: '#c2185b',
|
||||
text: '#607d8b',
|
||||
csv: '#00838f',
|
||||
file: '#546e7a',
|
||||
@@ -153,6 +158,7 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
||||
gif: 'scanner',
|
||||
bmp: 'scanner',
|
||||
bgremove: 'bgremove',
|
||||
imgedit: 'imgedit',
|
||||
txt: 'text',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
export type PixelRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ExportFormat = 'image/jpeg' | 'image/webp' | 'image/png';
|
||||
|
||||
export type RatioFit = 'crop' | 'pad';
|
||||
|
||||
const MIN_CROP = 8;
|
||||
|
||||
export function loadImageFromUrl(url: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('이미지를 불러오지 못했습니다.'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function clampCrop(
|
||||
crop: PixelRect,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): PixelRect {
|
||||
const maxW = Math.max(1, Math.floor(imageWidth));
|
||||
const maxH = Math.max(1, Math.floor(imageHeight));
|
||||
const width = Math.min(Math.max(MIN_CROP, Math.round(crop.width)), maxW);
|
||||
const height = Math.min(Math.max(MIN_CROP, Math.round(crop.height)), maxH);
|
||||
const x = Math.min(Math.max(0, Math.round(crop.x)), maxW - width);
|
||||
const y = Math.min(Math.max(0, Math.round(crop.y)), maxH - height);
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
export function fullImageCrop(imageWidth: number, imageHeight: number): PixelRect {
|
||||
return clampCrop(
|
||||
{ x: 0, y: 0, width: imageWidth, height: imageHeight },
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/** Largest rectangle of `aspect` (width/height) that fits in the image. */
|
||||
export function maxCropForAspect(
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
aspect: number,
|
||||
center?: { x: number; y: number },
|
||||
): PixelRect {
|
||||
if (!(aspect > 0) || !Number.isFinite(aspect)) {
|
||||
return fullImageCrop(imageWidth, imageHeight);
|
||||
}
|
||||
let width = imageWidth;
|
||||
let height = width / aspect;
|
||||
if (height > imageHeight) {
|
||||
height = imageHeight;
|
||||
width = height * aspect;
|
||||
}
|
||||
const cx = center?.x ?? imageWidth / 2;
|
||||
const cy = center?.y ?? imageHeight / 2;
|
||||
return clampCrop(
|
||||
{ x: cx - width / 2, y: cy - height / 2, width, height },
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function extensionForFormat(format: ExportFormat) {
|
||||
if (format === 'image/jpeg') return 'jpg';
|
||||
if (format === 'image/webp') return 'webp';
|
||||
return 'png';
|
||||
}
|
||||
|
||||
function outputSize(
|
||||
srcWidth: number,
|
||||
srcHeight: number,
|
||||
scale: number,
|
||||
maxEdge: number | null,
|
||||
padAspect: number | null,
|
||||
) {
|
||||
let width = Math.max(1, srcWidth);
|
||||
let height = Math.max(1, srcHeight);
|
||||
|
||||
if (padAspect && padAspect > 0) {
|
||||
const current = width / height;
|
||||
if (current < padAspect) {
|
||||
width = height * padAspect;
|
||||
} else if (current > padAspect) {
|
||||
height = width / padAspect;
|
||||
}
|
||||
}
|
||||
|
||||
const factor = Math.min(1, 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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(width)),
|
||||
height: Math.max(1, Math.round(height)),
|
||||
};
|
||||
}
|
||||
|
||||
export function previewOutputSize(options: {
|
||||
crop: PixelRect;
|
||||
scale: number;
|
||||
maxEdge: number | null;
|
||||
padAspect: number | null;
|
||||
fit: RatioFit;
|
||||
}) {
|
||||
const padAspect = options.fit === 'pad' ? options.padAspect : null;
|
||||
return outputSize(
|
||||
options.crop.width,
|
||||
options.crop.height,
|
||||
options.scale,
|
||||
options.maxEdge,
|
||||
padAspect,
|
||||
);
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
format: ExportFormat,
|
||||
quality: number,
|
||||
) {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const q = format === 'image/png' ? undefined : Math.min(1, Math.max(0.01, quality));
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('이미지를 인코딩하지 못했습니다.'));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
},
|
||||
format,
|
||||
q,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function parseCssColor(color: string): { r: number; g: number; b: number } {
|
||||
const hex = color.trim();
|
||||
const short = /^#([0-9a-f]{3})$/i.exec(hex);
|
||||
if (short) {
|
||||
const n = short[1];
|
||||
return {
|
||||
r: Number.parseInt(n[0] + n[0], 16),
|
||||
g: Number.parseInt(n[1] + n[1], 16),
|
||||
b: Number.parseInt(n[2] + n[2], 16),
|
||||
};
|
||||
}
|
||||
const full = /^#([0-9a-f]{6})$/i.exec(hex);
|
||||
if (full) {
|
||||
const n = full[1];
|
||||
return {
|
||||
r: Number.parseInt(n.slice(0, 2), 16),
|
||||
g: Number.parseInt(n.slice(2, 4), 16),
|
||||
b: Number.parseInt(n.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
return { r: 255, g: 255, b: 255 };
|
||||
}
|
||||
|
||||
export async function exportEditedImage(options: {
|
||||
image: HTMLImageElement;
|
||||
crop: PixelRect;
|
||||
scale: number;
|
||||
maxEdge: number | null;
|
||||
format: ExportFormat;
|
||||
quality: number;
|
||||
fit: RatioFit;
|
||||
padAspect: number | null;
|
||||
padColor: string;
|
||||
}): Promise<{ blob: Blob; width: number; height: number }> {
|
||||
const crop = clampCrop(
|
||||
options.crop,
|
||||
options.image.naturalWidth || options.image.width,
|
||||
options.image.naturalHeight || options.image.height,
|
||||
);
|
||||
const padAspect =
|
||||
options.fit === 'pad' && options.padAspect && options.padAspect > 0
|
||||
? options.padAspect
|
||||
: null;
|
||||
const size = outputSize(
|
||||
crop.width,
|
||||
crop.height,
|
||||
options.scale,
|
||||
options.maxEdge,
|
||||
padAspect,
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('캔버스를 만들 수 없습니다.');
|
||||
|
||||
if (options.format === 'image/jpeg' || padAspect) {
|
||||
const { r, g, b } = parseCssColor(options.padColor);
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
ctx.fillRect(0, 0, size.width, size.height);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, size.width, size.height);
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
if (padAspect) {
|
||||
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);
|
||||
ctx.drawImage(
|
||||
options.image,
|
||||
crop.x,
|
||||
crop.y,
|
||||
crop.width,
|
||||
crop.height,
|
||||
dx,
|
||||
dy,
|
||||
fitted.width,
|
||||
fitted.height,
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
options.image,
|
||||
crop.x,
|
||||
crop.y,
|
||||
crop.width,
|
||||
crop.height,
|
||||
0,
|
||||
0,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await canvasToBlob(canvas, options.format, options.quality);
|
||||
return { blob, width: size.width, height: size.height };
|
||||
}
|
||||
|
||||
function containSize(
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
boxW: number,
|
||||
boxH: number,
|
||||
) {
|
||||
const scale = Math.min(boxW / srcW, boxH / srcH);
|
||||
return {
|
||||
width: Math.max(1, Math.round(srcW * scale)),
|
||||
height: Math.max(1, Math.round(srcH * scale)),
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,11 @@ const router = createRouter({
|
||||
name: 'tools-bg-remove',
|
||||
component: () => import('@/views/ToolsBgRemoveView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tools/image-edit',
|
||||
name: 'tools-image-edit',
|
||||
component: () => import('@/views/ToolsImageEditView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/licenses',
|
||||
name: 'licenses',
|
||||
|
||||
@@ -87,6 +87,12 @@ const toolTypes = [
|
||||
title: '배경 제거',
|
||||
subtitle: '로고 단색 배경 · 투명 PNG',
|
||||
},
|
||||
{
|
||||
route: 'tools-image-edit',
|
||||
extension: 'imgedit',
|
||||
title: '이미지 편집',
|
||||
subtitle: '크롭 · 축소 · 경량화 · 비율',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
@@ -225,7 +231,7 @@ function onOpenTool(route: string) {
|
||||
<section class="section">
|
||||
<h2 class="section__title">도구</h2>
|
||||
<p class="section__lead">
|
||||
브라우저에서 문서를 스캔·보정하고 OCR이 포함된 PDF로 저장합니다.
|
||||
브라우저에서 문서를 스캔하고, 배경을 제거하며, 이미지를 크롭·축소·경량화합니다.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import CropEditor from '@/components/imageedit/CropEditor.vue';
|
||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import {
|
||||
exportEditedImage,
|
||||
extensionForFormat,
|
||||
formatBytes,
|
||||
fullImageCrop,
|
||||
loadImageFromUrl,
|
||||
maxCropForAspect,
|
||||
previewOutputSize,
|
||||
type ExportFormat,
|
||||
type PixelRect,
|
||||
type RatioFit,
|
||||
} from '@/integrations/imageedit/process';
|
||||
import { downloadBlob } from '@/utils/downloadBlob';
|
||||
|
||||
defineOptions({ name: 'ToolsImageEditView' });
|
||||
|
||||
const RATIO_PRESETS = [
|
||||
{ id: 'free', label: '자유', aspect: null as number | null },
|
||||
{ id: 'orig', label: '원본', aspect: -1 },
|
||||
{ id: '1:1', label: '1:1', aspect: 1 },
|
||||
{ id: '4:3', label: '4:3', aspect: 4 / 3 },
|
||||
{ id: '3:2', label: '3:2', aspect: 3 / 2 },
|
||||
{ id: '16:9', label: '16:9', aspect: 16 / 9 },
|
||||
{ id: '3:4', label: '3:4', aspect: 3 / 4 },
|
||||
{ id: '9:16', label: '9:16', aspect: 9 / 16 },
|
||||
] as const;
|
||||
|
||||
const MAX_EDGE_PRESETS = [
|
||||
{ id: '', label: '제한 없음' },
|
||||
{ id: '1920', label: '긴 변 1920px' },
|
||||
{ id: '1280', label: '긴 변 1280px' },
|
||||
{ id: '800', label: '긴 변 800px' },
|
||||
] as const;
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const filename = ref('이미지 편집');
|
||||
const sourceUrl = ref<string | null>(null);
|
||||
const sourceSize = ref(0);
|
||||
const imageWidth = ref(0);
|
||||
const imageHeight = ref(0);
|
||||
const loadedImage = ref<HTMLImageElement | null>(null);
|
||||
const crop = ref<PixelRect>({ x: 0, y: 0, width: 1, height: 1 });
|
||||
const ratioId = ref<(typeof RATIO_PRESETS)[number]['id']>('free');
|
||||
const fit = ref<RatioFit>('crop');
|
||||
const scalePercent = ref(100);
|
||||
const maxEdgeId = ref('');
|
||||
const format = ref<ExportFormat>('image/jpeg');
|
||||
const quality = ref(80);
|
||||
const padColor = ref('#ffffff');
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const imageUrlInput = ref('');
|
||||
const estimateBytes = ref<number | null>(null);
|
||||
const estimateBusy = ref(false);
|
||||
|
||||
usePageTitle(filename, { faviconKind: 'imgedit' });
|
||||
|
||||
const hasImage = computed(() => Boolean(sourceUrl.value && loadedImage.value));
|
||||
|
||||
const originalAspect = computed(() =>
|
||||
imageHeight.value ? imageWidth.value / imageHeight.value : 1,
|
||||
);
|
||||
|
||||
const cropAspect = computed(() => {
|
||||
const preset = RATIO_PRESETS.find((item) => item.id === ratioId.value);
|
||||
if (!preset || preset.id === 'free') return null;
|
||||
if (preset.id === 'orig') return originalAspect.value;
|
||||
return preset.aspect;
|
||||
});
|
||||
|
||||
const maxEdge = computed(() => {
|
||||
const n = Number(maxEdgeId.value);
|
||||
return n > 0 ? n : null;
|
||||
});
|
||||
|
||||
const outputHint = computed(() =>
|
||||
previewOutputSize({
|
||||
crop: crop.value,
|
||||
scale: scalePercent.value / 100,
|
||||
maxEdge: maxEdge.value,
|
||||
padAspect: cropAspect.value,
|
||||
fit: fit.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const qualityDisabled = computed(() => format.value === 'image/png');
|
||||
|
||||
function revoke(url: string | null) {
|
||||
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function baseName(name = filename.value) {
|
||||
return name.replace(/\.[^.]+$/, '') || 'image';
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function isImageFile(file: File) {
|
||||
if (file.type.startsWith('image/')) return true;
|
||||
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
|
||||
}
|
||||
|
||||
async function loadFile(file: File) {
|
||||
if (!isImageFile(file)) {
|
||||
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG 이미지를 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
estimateBytes.value = null;
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = await loadImageFromUrl(url);
|
||||
revoke(sourceUrl.value);
|
||||
sourceUrl.value = url;
|
||||
loadedImage.value = image;
|
||||
sourceSize.value = file.size;
|
||||
filename.value = file.name;
|
||||
imageWidth.value = image.naturalWidth || image.width;
|
||||
imageHeight.value = image.naturalHeight || image.height;
|
||||
ratioId.value = 'free';
|
||||
fit.value = 'crop';
|
||||
scalePercent.value = 100;
|
||||
maxEdgeId.value = '';
|
||||
crop.value = fullImageCrop(imageWidth.value, imageHeight.value);
|
||||
notice.value = `${imageWidth.value}×${imageHeight.value} · ${formatBytes(file.size)}`;
|
||||
} catch (reason) {
|
||||
revoke(url);
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '이미지를 열지 못했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromUrl(raw = imageUrlInput.value) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
error.value = '이미지 URL을 입력해 주세요.';
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
error.value = '올바른 이미지 URL을 입력해 주세요.';
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
error.value = 'http(s) URL만 사용할 수 있습니다.';
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await fetch(parsed.href, { mode: 'cors' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`이미지를 가져오지 못했습니다 (${response.status}).`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const looksImage =
|
||||
blob.type.startsWith('image/') ||
|
||||
/\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(parsed.pathname);
|
||||
if (!looksImage) {
|
||||
throw new Error('응답이 이미지가 아닙니다.');
|
||||
}
|
||||
const leaf = decodeURIComponent(
|
||||
parsed.pathname.split('/').filter(Boolean).pop() || 'image',
|
||||
);
|
||||
const name = /\.[a-z0-9]+$/i.test(leaf) ? leaf : `${leaf}.png`;
|
||||
const file = new File([blob], name, {
|
||||
type: blob.type || 'image/png',
|
||||
});
|
||||
imageUrlInput.value = parsed.href;
|
||||
await loadFile(file);
|
||||
} catch (reason) {
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : 'URL에서 이미지를 불러오지 못했습니다.';
|
||||
error.value = /Failed to fetch|NetworkError|CORS/i.test(message)
|
||||
? '이미지를 가져오지 못했습니다. CORS를 허용하는 URL인지 확인해 주세요.'
|
||||
: message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFilesSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file) void loadFile(file);
|
||||
}
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop: onZoneDrop,
|
||||
} = useFileDropZone((files) => {
|
||||
const file = files[0];
|
||||
if (file) void loadFile(file);
|
||||
});
|
||||
|
||||
function resetCrop() {
|
||||
if (!imageWidth.value) return;
|
||||
if (fit.value === 'crop' && cropAspect.value) {
|
||||
crop.value = maxCropForAspect(
|
||||
imageWidth.value,
|
||||
imageHeight.value,
|
||||
cropAspect.value,
|
||||
);
|
||||
} else {
|
||||
crop.value = fullImageCrop(imageWidth.value, imageHeight.value);
|
||||
}
|
||||
}
|
||||
|
||||
watch(ratioId, () => {
|
||||
if (!hasImage.value) return;
|
||||
if (fit.value === 'crop' && cropAspect.value) {
|
||||
const center = {
|
||||
x: crop.value.x + crop.value.width / 2,
|
||||
y: crop.value.y + crop.value.height / 2,
|
||||
};
|
||||
crop.value = maxCropForAspect(
|
||||
imageWidth.value,
|
||||
imageHeight.value,
|
||||
cropAspect.value,
|
||||
center,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
watch(fit, (next) => {
|
||||
if (next === 'crop' && cropAspect.value && hasImage.value) {
|
||||
crop.value = maxCropForAspect(
|
||||
imageWidth.value,
|
||||
imageHeight.value,
|
||||
cropAspect.value,
|
||||
{
|
||||
x: crop.value.x + crop.value.width / 2,
|
||||
y: crop.value.y + crop.value.height / 2,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let estimateTimer = 0;
|
||||
let estimateGen = 0;
|
||||
|
||||
watch(
|
||||
[
|
||||
crop,
|
||||
scalePercent,
|
||||
maxEdgeId,
|
||||
format,
|
||||
quality,
|
||||
fit,
|
||||
ratioId,
|
||||
padColor,
|
||||
loadedImage,
|
||||
],
|
||||
() => {
|
||||
window.clearTimeout(estimateTimer);
|
||||
if (!loadedImage.value) {
|
||||
estimateBytes.value = null;
|
||||
return;
|
||||
}
|
||||
estimateTimer = window.setTimeout(() => {
|
||||
void refreshEstimate();
|
||||
}, 350);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
async function refreshEstimate() {
|
||||
const image = loadedImage.value;
|
||||
if (!image) return;
|
||||
const gen = ++estimateGen;
|
||||
estimateBusy.value = true;
|
||||
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,
|
||||
});
|
||||
if (gen !== estimateGen) return;
|
||||
estimateBytes.value = result.blob.size;
|
||||
} catch {
|
||||
if (gen !== estimateGen) return;
|
||||
estimateBytes.value = null;
|
||||
} finally {
|
||||
if (gen === estimateGen) estimateBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImage() {
|
||||
const image = loadedImage.value;
|
||||
if (!image) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
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,
|
||||
});
|
||||
const ext = extensionForFormat(format.value);
|
||||
downloadBlob(result.blob, `${baseName()}-edited.${ext}`);
|
||||
notice.value = `${result.width}×${result.height} · ${formatBytes(result.blob.size)} 저장`;
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '이미지를 저장하지 못했습니다.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(estimateTimer);
|
||||
estimateGen += 1;
|
||||
revoke(sourceUrl.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div
|
||||
class="edit-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onZoneDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="hasImage"
|
||||
save-label="다운로드"
|
||||
:busy="busy"
|
||||
@open="onOpen"
|
||||
@save="void saveImage()"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/svg+xml,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg"
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
|
||||
|
||||
<main class="workspace">
|
||||
<div
|
||||
v-if="!hasImage"
|
||||
class="empty"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
>
|
||||
<strong>이미지 편집</strong>
|
||||
<p>
|
||||
사진을 열거나 끌어다 놓은 뒤 크롭, 축소, 경량화, 비율 변경을 적용해
|
||||
JPEG·WebP·PNG로 저장합니다. 서버에 업로드하지 않습니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="busy"
|
||||
@click="onOpen"
|
||||
>
|
||||
이미지 열기
|
||||
</button>
|
||||
<form class="url-form" @submit.prevent="void loadFromUrl()">
|
||||
<input
|
||||
v-model="imageUrlInput"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
:disabled="busy"
|
||||
aria-label="이미지 URL"
|
||||
/>
|
||||
<button type="submit" :disabled="busy">URL 불러오기</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="layout">
|
||||
<aside class="controls">
|
||||
<form
|
||||
class="url-form url-form--stack"
|
||||
@submit.prevent="void loadFromUrl()"
|
||||
>
|
||||
<input
|
||||
v-model="imageUrlInput"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="이미지 URL"
|
||||
:disabled="busy"
|
||||
aria-label="이미지 URL"
|
||||
/>
|
||||
<button type="submit" :disabled="busy">URL</button>
|
||||
</form>
|
||||
|
||||
<section class="group">
|
||||
<h2>크롭</h2>
|
||||
<p class="hint">
|
||||
영역을 드래그해 자르세요. {{ crop.width }}×{{ crop.height }}px
|
||||
</p>
|
||||
<button type="button" class="btn-ghost" @click="resetCrop">
|
||||
영역 초기화
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>비율 변경</h2>
|
||||
<div class="chips" role="list">
|
||||
<button
|
||||
v-for="preset in RATIO_PRESETS"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:aria-pressed="ratioId === preset.id"
|
||||
@click="ratioId = preset.id"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="field field--inline">
|
||||
<span>맞추는 방법</span>
|
||||
<select v-model="fit" :disabled="!cropAspect">
|
||||
<option value="crop">잘라내기</option>
|
||||
<option value="pad">여백 추가</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="fit === 'pad' && cropAspect" class="field field--inline">
|
||||
<span>여백 색</span>
|
||||
<input v-model="padColor" type="color" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>축소</h2>
|
||||
<label class="field">
|
||||
<span class="field__row">
|
||||
배율
|
||||
<em>{{ scalePercent }}%</em>
|
||||
</span>
|
||||
<input
|
||||
v-model.number="scalePercent"
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
step="1"
|
||||
/>
|
||||
</label>
|
||||
<label class="field field--inline">
|
||||
<span>최대 크기</span>
|
||||
<select v-model="maxEdgeId">
|
||||
<option
|
||||
v-for="preset in MAX_EDGE_PRESETS"
|
||||
:key="preset.id"
|
||||
:value="preset.id"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>경량화</h2>
|
||||
<label class="field field--inline">
|
||||
<span>형식</span>
|
||||
<select v-model="format">
|
||||
<option value="image/jpeg">JPEG</option>
|
||||
<option value="image/webp">WebP</option>
|
||||
<option value="image/png">PNG (무손실)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__row">
|
||||
품질
|
||||
<em>{{ qualityDisabled ? '해당 없음' : quality }}</em>
|
||||
</span>
|
||||
<input
|
||||
v-model.number="quality"
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
step="1"
|
||||
:disabled="qualityDisabled"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group group--stat">
|
||||
<p>
|
||||
출력
|
||||
<strong
|
||||
>{{ outputHint.width }}×{{ outputHint.height }}</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
용량
|
||||
<strong v-if="estimateBytes != null">{{
|
||||
formatBytes(estimateBytes)
|
||||
}}</strong>
|
||||
<strong v-else-if="estimateBusy">계산 중…</strong>
|
||||
<strong v-else>—</strong>
|
||||
<span v-if="sourceSize" class="muted">
|
||||
/ 원본 {{ formatBytes(sourceSize) }}
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="busy"
|
||||
@click="void saveImage()"
|
||||
>
|
||||
다운로드
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="stage">
|
||||
<CropEditor
|
||||
v-if="sourceUrl"
|
||||
:src="sourceUrl"
|
||||
:crop="crop"
|
||||
:image-width="imageWidth"
|
||||
:image-height="imageHeight"
|
||||
:aspect="fit === 'crop' ? cropAspect : null"
|
||||
@update:crop="crop = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="dropActive && hasImage"
|
||||
class="drop-overlay"
|
||||
aria-hidden="true"
|
||||
>
|
||||
이미지를 놓아 바꾸기
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
height: 100%;
|
||||
min-height: min(60vh, 28rem);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--ink);
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.empty.is-drop-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.group h2 {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.group--stat p {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
min-height: 2rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.field--inline {
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field__row em {
|
||||
font-style: normal;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field select,
|
||||
.field input[type='range'],
|
||||
.field input[type='color'] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.field select {
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.45rem;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.field input[type='color'] {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.15rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-ghost {
|
||||
min-height: 2.5rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
justify-self: start;
|
||||
min-height: 2.1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.url-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
width: min(100%, 28rem);
|
||||
}
|
||||
|
||||
.url-form--stack {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.url-form input {
|
||||
flex: 1 1 12rem;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.url-form button {
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.url-form button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stage {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0.75rem;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: #b42318;
|
||||
background: color-mix(in srgb, #b42318 10%, var(--surface));
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -56,6 +56,9 @@ const coarsePointer = ref(false);
|
||||
const mobilePane = ref<'edit' | 'preview'>('edit');
|
||||
const queueOpen = ref(false);
|
||||
const settingsOpen = ref(false);
|
||||
const warpPromptOpen = ref(false);
|
||||
/** `열기` replaces the queue; `파일 추가`/카메라/드롭은 뒤에 덧붙입니다. */
|
||||
const replaceOnSelect = ref(false);
|
||||
const mediaQueryCleanups: Array<() => void> = [];
|
||||
|
||||
function refreshPointerMode() {
|
||||
@@ -72,7 +75,9 @@ function refreshPointerMode() {
|
||||
function syncBodyScrollLock() {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.style.overflow =
|
||||
queueOpen.value || settingsOpen.value ? 'hidden' : '';
|
||||
queueOpen.value || settingsOpen.value || warpPromptOpen.value
|
||||
? 'hidden'
|
||||
: '';
|
||||
}
|
||||
|
||||
usePageTitle(filename, { faviconKind: 'scanner' });
|
||||
@@ -119,7 +124,7 @@ async function pushPageFromSource(
|
||||
sourceWidth: w,
|
||||
sourceHeight: h,
|
||||
corners,
|
||||
aspect: 'a4-portrait',
|
||||
aspect: 'auto',
|
||||
scanIntensity: DEFAULT_SCAN_INTENSITY,
|
||||
warpedUrl: null,
|
||||
warpedWidth: 0,
|
||||
@@ -131,13 +136,14 @@ async function pushPageFromSource(
|
||||
filename.value = name;
|
||||
}
|
||||
|
||||
async function addFiles(files: File[]) {
|
||||
async function addFiles(files: File[], replace = false) {
|
||||
const images = files.filter(isScannerImageFile);
|
||||
const pdfs = files.filter(isPdfFile);
|
||||
if (!images.length && !pdfs.length) {
|
||||
error.value = 'PNG, JPEG, WebP 이미지 또는 PDF를 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
if (replace) clearPages();
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
progressLabel.value = '파일 준비 중';
|
||||
@@ -167,9 +173,10 @@ async function addFiles(files: File[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const verb = replace ? '열었습니다' : '추가했습니다';
|
||||
notice.value = coarsePointer.value
|
||||
? `${added}장을 추가했습니다. 화살표로 순서를 바꿀 수 있습니다.`
|
||||
: `${added}장을 추가했습니다. 드래그로 순서를 바꿀 수 있습니다.`;
|
||||
? `${added}장을 ${verb}. 화살표로 순서를 바꿀 수 있습니다.`
|
||||
: `${added}장을 ${verb}. 드래그로 순서를 바꿀 수 있습니다.`;
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error
|
||||
@@ -227,7 +234,7 @@ watch(activePage, (page) => {
|
||||
if (page) filename.value = page.name;
|
||||
});
|
||||
|
||||
watch([queueOpen, settingsOpen], () => {
|
||||
watch([queueOpen, settingsOpen, warpPromptOpen], () => {
|
||||
syncBodyScrollLock();
|
||||
});
|
||||
|
||||
@@ -237,23 +244,46 @@ watch(
|
||||
if (!count) {
|
||||
queueOpen.value = false;
|
||||
settingsOpen.value = false;
|
||||
warpPromptOpen.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function clearPages() {
|
||||
for (const page of pages.value) {
|
||||
revokeUrl(page.sourceUrl);
|
||||
revokeUrl(page.warpedUrl);
|
||||
}
|
||||
pages.value = [];
|
||||
activeId.value = null;
|
||||
filename.value = '문서 스캔';
|
||||
mobilePane.value = 'edit';
|
||||
warpPromptOpen.value = false;
|
||||
}
|
||||
|
||||
/** Toolbar `열기`: discards the current queue and starts over. */
|
||||
function onOpen() {
|
||||
replaceOnSelect.value = true;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onAddFiles() {
|
||||
replaceOnSelect.value = false;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onCamera() {
|
||||
replaceOnSelect.value = false;
|
||||
cameraInput.value?.click();
|
||||
}
|
||||
|
||||
function onFilesSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selected = Array.from(input.files ?? []);
|
||||
const replace = replaceOnSelect.value;
|
||||
replaceOnSelect.value = false;
|
||||
input.value = '';
|
||||
if (selected.length) void addFiles(selected);
|
||||
if (selected.length) void addFiles(selected, replace);
|
||||
}
|
||||
|
||||
function selectPage(id: string) {
|
||||
@@ -277,6 +307,27 @@ function closeSettings() {
|
||||
settingsOpen.value = false;
|
||||
}
|
||||
|
||||
/** Ask before showing a preview that has no correction applied yet. */
|
||||
function requestWarpPrompt() {
|
||||
if (busy.value) return;
|
||||
if (!activePage.value || activePage.value.warpedUrl) return;
|
||||
warpPromptOpen.value = true;
|
||||
}
|
||||
|
||||
function showPreviewPane() {
|
||||
mobilePane.value = 'preview';
|
||||
requestWarpPrompt();
|
||||
}
|
||||
|
||||
function closeWarpPrompt() {
|
||||
warpPromptOpen.value = false;
|
||||
}
|
||||
|
||||
async function confirmWarpPrompt() {
|
||||
warpPromptOpen.value = false;
|
||||
await warpActive();
|
||||
}
|
||||
|
||||
function removePage(id: string) {
|
||||
const index = pages.value.findIndex((page) => page.id === id);
|
||||
if (index < 0) return;
|
||||
@@ -646,7 +697,7 @@ const progressPercent = computed(() =>
|
||||
<div class="layout" :class="{ 'has-pages': pages.length > 0 }">
|
||||
<aside v-if="pages.length" class="queue queue--rail">
|
||||
<div class="queue__actions">
|
||||
<button type="button" :disabled="busy" @click="onOpen">
|
||||
<button type="button" :disabled="busy" @click="onAddFiles">
|
||||
파일 추가
|
||||
</button>
|
||||
<button
|
||||
@@ -758,7 +809,7 @@ const progressPercent = computed(() =>
|
||||
</button>
|
||||
</header>
|
||||
<div class="queue-sheet__actions">
|
||||
<button type="button" :disabled="busy" @click="onOpen">
|
||||
<button type="button" :disabled="busy" @click="onAddFiles">
|
||||
파일 추가
|
||||
</button>
|
||||
<button
|
||||
@@ -1062,6 +1113,43 @@ const progressPercent = computed(() =>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="warpPromptOpen"
|
||||
class="warp-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="warp-modal-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="warp-modal__backdrop"
|
||||
aria-label="닫기"
|
||||
@click="closeWarpPrompt"
|
||||
/>
|
||||
<div class="warp-modal__panel">
|
||||
<h2 id="warp-modal-title">지금 보정할까요?</h2>
|
||||
<p>
|
||||
이 장은 아직 원근 보정을 하지 않았습니다. 보정하면 코너 기준으로
|
||||
문서를 펴서 미리보기에 표시합니다.
|
||||
</p>
|
||||
<div class="warp-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="warp-modal__primary"
|
||||
:disabled="busy"
|
||||
@click="void confirmWarpPrompt()"
|
||||
>
|
||||
보정하기
|
||||
</button>
|
||||
<button type="button" @click="closeWarpPrompt">
|
||||
나중에
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="pane-tabs" role="tablist" aria-label="편집 보기">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1077,7 +1165,7 @@ const progressPercent = computed(() =>
|
||||
role="tab"
|
||||
:aria-selected="mobilePane === 'preview'"
|
||||
:class="{ active: mobilePane === 'preview' }"
|
||||
@click="mobilePane = 'preview'"
|
||||
@click="showPreviewPane"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
@@ -1101,7 +1189,16 @@ const progressPercent = computed(() =>
|
||||
:src="activePage.warpedUrl"
|
||||
alt="보정된 스캔"
|
||||
/>
|
||||
<p v-else class="muted">보정 후 미리보기가 표시됩니다.</p>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="preview-empty"
|
||||
:disabled="busy"
|
||||
@click="requestWarpPrompt"
|
||||
>
|
||||
<strong>미리보기 준비</strong>
|
||||
<span>눌러서 이 장을 보정합니다.</span>
|
||||
</button>
|
||||
<div v-if="activePage.ocr" class="ocr-box">
|
||||
<h3>OCR 결과</h3>
|
||||
<pre>{{ activePage.ocr.text || '(텍스트 없음)' }}</pre>
|
||||
@@ -1567,6 +1664,111 @@ const progressPercent = computed(() =>
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
justify-items: start;
|
||||
width: 100%;
|
||||
padding: 1.1rem 0.9rem;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-empty:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.preview-empty:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.preview-empty strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-empty span {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.warp-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 95;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.warp-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: color-mix(in srgb, black 45%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warp-modal__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 24rem);
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 1.1rem 1.15rem 1.15rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 12px 32px color-mix(in srgb, black 28%, transparent);
|
||||
}
|
||||
|
||||
.warp-modal__panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.warp-modal__panel p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warp-modal__actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.warp-modal__actions button {
|
||||
flex: 1;
|
||||
min-height: 2.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warp-modal__primary {
|
||||
border-color: var(--accent) !important;
|
||||
background: var(--accent-soft) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warp-modal__actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ocr-box {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user