Add a browser image editor and split scanner Open from Add files.
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:
ysj7705andCursor committed 2026-09-14 11:38:49 +09:00
1 parent 194eb318d1
commit 26c98fe5e8
8 files changed
+1651 -13

No files matched your search

+3 -1
View File
@@ -28,7 +28,8 @@ const viewKey = computed(() => {
route.name === 'illustrator-viewer' || route.name === 'illustrator-viewer' ||
route.name === 'photoshop-viewer' || route.name === 'photoshop-viewer' ||
route.name === 'tools-scanner' || route.name === 'tools-scanner' ||
route.name === 'tools-bg-remove' route.name === 'tools-bg-remove' ||
route.name === 'tools-image-edit'
) { ) {
return String(route.name); return String(route.name);
} }
@@ -50,6 +51,7 @@ const viewKey = computed(() => {
'PhotoshopViewerView', 'PhotoshopViewerView',
'ToolsScannerView', 'ToolsScannerView',
'ToolsBgRemoveView', 'ToolsBgRemoveView',
'ToolsImageEditView',
]" ]"
:max="10" :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>
+6
View File
@@ -22,6 +22,7 @@ export type FaviconKind =
| 'photoshop' | 'photoshop'
| 'scanner' | 'scanner'
| 'bgremove' | 'bgremove'
| 'imgedit'
| 'text' | 'text'
| 'csv' | 'csv'
| 'file'; | 'file';
@@ -51,6 +52,9 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z', scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
bgremove: 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', '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', 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', 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', 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', photoshop: '#31a8ff',
scanner: '#00897b', scanner: '#00897b',
bgremove: '#5c6bc0', bgremove: '#5c6bc0',
imgedit: '#c2185b',
text: '#607d8b', text: '#607d8b',
csv: '#00838f', csv: '#00838f',
file: '#546e7a', file: '#546e7a',
@@ -153,6 +158,7 @@ const EXT_KIND: Record<string, FaviconKind> = {
gif: 'scanner', gif: 'scanner',
bmp: 'scanner', bmp: 'scanner',
bgremove: 'bgremove', bgremove: 'bgremove',
imgedit: 'imgedit',
txt: 'text', 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)),
};
}
+5
View File
@@ -64,6 +64,11 @@ const router = createRouter({
name: 'tools-bg-remove', name: 'tools-bg-remove',
component: () => import('@/views/ToolsBgRemoveView.vue'), component: () => import('@/views/ToolsBgRemoveView.vue'),
}, },
{
path: '/tools/image-edit',
name: 'tools-image-edit',
component: () => import('@/views/ToolsImageEditView.vue'),
},
{ {
path: '/licenses', path: '/licenses',
name: 'licenses', name: 'licenses',
+7 -1
View File
@@ -87,6 +87,12 @@ const toolTypes = [
title: '배경 제거', title: '배경 제거',
subtitle: '로고 단색 배경 · 투명 PNG', subtitle: '로고 단색 배경 · 투명 PNG',
}, },
{
route: 'tools-image-edit',
extension: 'imgedit',
title: '이미지 편집',
subtitle: '크롭 · 축소 · 경량화 · 비율',
},
] as const; ] as const;
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
@@ -225,7 +231,7 @@ function onOpenTool(route: string) {
<section class="section"> <section class="section">
<h2 class="section__title">도구</h2> <h2 class="section__title">도구</h2>
<p class="section__lead"> <p class="section__lead">
브라우저에서 문서를 스캔·보정하고 OCR이 포함된 PDF로 저장합니다. 브라우저에서 문서를 스캔하고, 배경을 제거하며, 이미지를 크롭·축소·경량화합니다.
</p> </p>
<div class="grid"> <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>
+213 -11
View File
@@ -56,6 +56,9 @@ const coarsePointer = ref(false);
const mobilePane = ref<'edit' | 'preview'>('edit'); const mobilePane = ref<'edit' | 'preview'>('edit');
const queueOpen = ref(false); const queueOpen = ref(false);
const settingsOpen = ref(false); const settingsOpen = ref(false);
const warpPromptOpen = ref(false);
/** `열기` replaces the queue; `파일 추가`/카메라/드롭은 뒤에 덧붙입니다. */
const replaceOnSelect = ref(false);
const mediaQueryCleanups: Array<() => void> = []; const mediaQueryCleanups: Array<() => void> = [];
function refreshPointerMode() { function refreshPointerMode() {
@@ -72,7 +75,9 @@ function refreshPointerMode() {
function syncBodyScrollLock() { function syncBodyScrollLock() {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
document.body.style.overflow = document.body.style.overflow =
queueOpen.value || settingsOpen.value ? 'hidden' : ''; queueOpen.value || settingsOpen.value || warpPromptOpen.value
? 'hidden'
: '';
} }
usePageTitle(filename, { faviconKind: 'scanner' }); usePageTitle(filename, { faviconKind: 'scanner' });
@@ -119,7 +124,7 @@ async function pushPageFromSource(
sourceWidth: w, sourceWidth: w,
sourceHeight: h, sourceHeight: h,
corners, corners,
aspect: 'a4-portrait', aspect: 'auto',
scanIntensity: DEFAULT_SCAN_INTENSITY, scanIntensity: DEFAULT_SCAN_INTENSITY,
warpedUrl: null, warpedUrl: null,
warpedWidth: 0, warpedWidth: 0,
@@ -131,13 +136,14 @@ async function pushPageFromSource(
filename.value = name; filename.value = name;
} }
async function addFiles(files: File[]) { async function addFiles(files: File[], replace = false) {
const images = files.filter(isScannerImageFile); const images = files.filter(isScannerImageFile);
const pdfs = files.filter(isPdfFile); const pdfs = files.filter(isPdfFile);
if (!images.length && !pdfs.length) { if (!images.length && !pdfs.length) {
error.value = 'PNG, JPEG, WebP 이미지 또는 PDF를 선택해 주세요.'; error.value = 'PNG, JPEG, WebP 이미지 또는 PDF를 선택해 주세요.';
return; return;
} }
if (replace) clearPages();
error.value = ''; error.value = '';
busy.value = true; busy.value = true;
progressLabel.value = '파일 준비 중'; progressLabel.value = '파일 준비 중';
@@ -167,9 +173,10 @@ async function addFiles(files: File[]) {
} }
} }
const verb = replace ? '열었습니다' : '추가했습니다';
notice.value = coarsePointer.value notice.value = coarsePointer.value
? `${added}장을 추가했습니다. 화살표로 순서를 바꿀 수 있습니다.` ? `${added}장을 ${verb}. 화살표로 순서를 바꿀 수 있습니다.`
: `${added}장을 추가했습니다. 드래그로 순서를 바꿀 수 있습니다.`; : `${added}장을 ${verb}. 드래그로 순서를 바꿀 수 있습니다.`;
} catch (reason) { } catch (reason) {
error.value = error.value =
reason instanceof Error reason instanceof Error
@@ -227,7 +234,7 @@ watch(activePage, (page) => {
if (page) filename.value = page.name; if (page) filename.value = page.name;
}); });
watch([queueOpen, settingsOpen], () => { watch([queueOpen, settingsOpen, warpPromptOpen], () => {
syncBodyScrollLock(); syncBodyScrollLock();
}); });
@@ -237,23 +244,46 @@ watch(
if (!count) { if (!count) {
queueOpen.value = false; queueOpen.value = false;
settingsOpen.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() { function onOpen() {
replaceOnSelect.value = true;
fileInput.value?.click();
}
function onAddFiles() {
replaceOnSelect.value = false;
fileInput.value?.click(); fileInput.value?.click();
} }
function onCamera() { function onCamera() {
replaceOnSelect.value = false;
cameraInput.value?.click(); cameraInput.value?.click();
} }
function onFilesSelected(event: Event) { function onFilesSelected(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const selected = Array.from(input.files ?? []); const selected = Array.from(input.files ?? []);
const replace = replaceOnSelect.value;
replaceOnSelect.value = false;
input.value = ''; input.value = '';
if (selected.length) void addFiles(selected); if (selected.length) void addFiles(selected, replace);
} }
function selectPage(id: string) { function selectPage(id: string) {
@@ -277,6 +307,27 @@ function closeSettings() {
settingsOpen.value = false; 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) { function removePage(id: string) {
const index = pages.value.findIndex((page) => page.id === id); const index = pages.value.findIndex((page) => page.id === id);
if (index < 0) return; if (index < 0) return;
@@ -646,7 +697,7 @@ const progressPercent = computed(() =>
<div class="layout" :class="{ 'has-pages': pages.length > 0 }"> <div class="layout" :class="{ 'has-pages': pages.length > 0 }">
<aside v-if="pages.length" class="queue queue--rail"> <aside v-if="pages.length" class="queue queue--rail">
<div class="queue__actions"> <div class="queue__actions">
<button type="button" :disabled="busy" @click="onOpen"> <button type="button" :disabled="busy" @click="onAddFiles">
파일 추가 파일 추가
</button> </button>
<button <button
@@ -758,7 +809,7 @@ const progressPercent = computed(() =>
</button> </button>
</header> </header>
<div class="queue-sheet__actions"> <div class="queue-sheet__actions">
<button type="button" :disabled="busy" @click="onOpen"> <button type="button" :disabled="busy" @click="onAddFiles">
파일 추가 파일 추가
</button> </button>
<button <button
@@ -1062,6 +1113,43 @@ const progressPercent = computed(() =>
</div> </div>
</Teleport> </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="편집 보기"> <div class="pane-tabs" role="tablist" aria-label="편집 보기">
<button <button
type="button" type="button"
@@ -1077,7 +1165,7 @@ const progressPercent = computed(() =>
role="tab" role="tab"
:aria-selected="mobilePane === 'preview'" :aria-selected="mobilePane === 'preview'"
:class="{ active: mobilePane === 'preview' }" :class="{ active: mobilePane === 'preview' }"
@click="mobilePane = 'preview'" @click="showPreviewPane"
> >
미리보기 미리보기
</button> </button>
@@ -1101,7 +1189,16 @@ const progressPercent = computed(() =>
:src="activePage.warpedUrl" :src="activePage.warpedUrl"
alt="보정된 스캔" 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"> <div v-if="activePage.ocr" class="ocr-box">
<h3>OCR 결과</h3> <h3>OCR 결과</h3>
<pre>{{ activePage.ocr.text || '(텍스트 없음)' }}</pre> <pre>{{ activePage.ocr.text || '(텍스트 없음)' }}</pre>
@@ -1567,6 +1664,111 @@ const progressPercent = computed(() =>
background: #111; 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 { .ocr-box {
margin-top: 0.75rem; margin-top: 0.75rem;
} }