Files
sejong-office/packages/frontend/src/views/ToolsScannerView.vue
T
ysj7705andCursor 26c98fe5e8
office.sejong.dev/pipeline/head This commit looks good
Add a browser image editor and split scanner Open from Add files.
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>
2026-09-14 11:38:49 +09:00

2181 lines
54 KiB
Vue

<script setup lang="ts">
import {
computed,
onActivated,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import CornerEditor from '@/components/scanner/CornerEditor.vue';
import { consumeScannerFiles } from '@/composables/scannerSession';
import { usePageTitle } from '@/composables/usePageTitle';
import {
detectCornersFromImage,
ensureJscanify,
fullFrameCorners,
loadImageFromUrl,
} from '@/integrations/scanner/detect';
import { buildSearchablePdf } from '@/integrations/scanner/buildPdf';
import { recognizeImage, terminateOcrWorker } from '@/integrations/scanner/ocr';
import {
isPdfFile,
isScannerImageFile,
renderPdfToImages,
} from '@/integrations/scanner/pdfPages';
import type {
CornerPoints,
PaperAspect,
ScannerPage,
} from '@/integrations/scanner/types';
import { warpImage, DEFAULT_SCAN_INTENSITY } from '@/integrations/scanner/warp';
import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'ToolsScannerView' });
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const cameraInput = ref<HTMLInputElement | null>(null);
const pages = ref<ScannerPage[]>([]);
const activeId = ref<string | null>(null);
const filename = ref('문서 스캔');
const busy = ref(false);
const progress = ref(0);
const progressLabel = ref('');
const error = ref('');
const notice = ref('');
const engineReady = ref(false);
const enableOcr = ref(true);
const fileDropActive = ref(false);
const dragPageId = ref<string | null>(null);
const dropTargetId = ref<string | null>(null);
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() {
if (typeof window === 'undefined') return;
const narrow = window.matchMedia('(max-width: 900px)').matches;
coarsePointer.value =
window.matchMedia('(pointer: coarse)').matches || narrow;
if (!narrow) {
queueOpen.value = false;
settingsOpen.value = false;
}
}
function syncBodyScrollLock() {
if (typeof document === 'undefined') return;
document.body.style.overflow =
queueOpen.value || settingsOpen.value || warpPromptOpen.value
? 'hidden'
: '';
}
usePageTitle(filename, { faviconKind: 'scanner' });
const activePage = computed(
() => pages.value.find((page) => page.id === activeId.value) ?? null,
);
const activePageIndex = computed(() =>
pages.value.findIndex((page) => page.id === activeId.value),
);
const warpedCount = computed(
() => pages.value.filter((page) => page.warpedUrl).length,
);
function revokeUrl(url: string | null) {
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
}
function createId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
async function pushPageFromSource(
name: string,
sourceUrl: string,
width: number,
height: number,
) {
const image = await loadImageFromUrl(sourceUrl);
const w = width || image.naturalWidth || image.width;
const h = height || image.naturalHeight || image.height;
let corners: CornerPoints;
try {
corners = detectCornersFromImage(image);
} catch {
corners = fullFrameCorners(w, h);
}
const page: ScannerPage = {
id: createId(),
name,
sourceUrl,
sourceWidth: w,
sourceHeight: h,
corners,
aspect: 'auto',
scanIntensity: DEFAULT_SCAN_INTENSITY,
warpedUrl: null,
warpedWidth: 0,
warpedHeight: 0,
ocr: null,
};
pages.value.push(page);
if (!activeId.value) activeId.value = page.id;
filename.value = name;
}
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 = '파일 준비 중';
let added = 0;
try {
await ensureJscanify();
engineReady.value = true;
for (const file of images) {
progressLabel.value = `${file.name} 분석 중`;
const sourceUrl = URL.createObjectURL(file);
await pushPageFromSource(file.name, sourceUrl, 0, 0);
added += 1;
}
for (const file of pdfs) {
progressLabel.value = `${file.name} PDF 변환 중`;
const rendered = await renderPdfToImages(file, (done, total) => {
progress.value = total ? done / total : 0;
progressLabel.value = `${file.name} 페이지 ${done}/${total}`;
});
for (const page of rendered) {
progressLabel.value = `${page.name} 분석 중`;
const sourceUrl = URL.createObjectURL(page.blob);
await pushPageFromSource(page.name, sourceUrl, page.width, page.height);
added += 1;
}
}
const verb = replace ? '열었습니다' : '추가했습니다';
notice.value = coarsePointer.value
? `${added}장을 ${verb}. 화살표로 순서를 바꿀 수 있습니다.`
: `${added}장을 ${verb}. 드래그로 순서를 바꿀 수 있습니다.`;
} catch (reason) {
error.value =
reason instanceof Error
? reason.message
: '스캐너를 초기화하지 못했습니다.';
} finally {
busy.value = false;
progress.value = 0;
progressLabel.value = '';
}
}
function consumePending() {
const pending = consumeScannerFiles();
if (pending.length) void addFiles(pending);
}
onMounted(() => {
refreshPointerMode();
const narrowMq = window.matchMedia('(max-width: 900px)');
const coarseMq = window.matchMedia('(pointer: coarse)');
narrowMq.addEventListener('change', refreshPointerMode);
coarseMq.addEventListener('change', refreshPointerMode);
mediaQueryCleanups.push(() => {
narrowMq.removeEventListener('change', refreshPointerMode);
coarseMq.removeEventListener('change', refreshPointerMode);
});
void ensureJscanify()
.then(() => {
engineReady.value = true;
})
.catch((reason) => {
error.value =
reason instanceof Error
? reason.message
: 'OpenCV/jscanify를 불러오지 못했습니다.';
});
consumePending();
});
onActivated(consumePending);
onBeforeUnmount(() => {
for (const cleanup of mediaQueryCleanups) cleanup();
mediaQueryCleanups.length = 0;
if (typeof document !== 'undefined') document.body.style.overflow = '';
for (const page of pages.value) {
revokeUrl(page.sourceUrl);
revokeUrl(page.warpedUrl);
}
void terminateOcrWorker();
});
watch(activePage, (page) => {
if (page) filename.value = page.name;
});
watch([queueOpen, settingsOpen, warpPromptOpen], () => {
syncBodyScrollLock();
});
watch(
() => pages.value.length,
(count) => {
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, replace);
}
function selectPage(id: string) {
activeId.value = id;
if (coarsePointer.value) queueOpen.value = false;
}
function openQueue() {
queueOpen.value = true;
}
function closeQueue() {
queueOpen.value = false;
}
function openSettings() {
settingsOpen.value = true;
}
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;
const [removed] = pages.value.splice(index, 1);
revokeUrl(removed.sourceUrl);
revokeUrl(removed.warpedUrl);
if (activeId.value === id) {
activeId.value = pages.value[index]?.id ?? pages.value[index - 1]?.id ?? null;
}
}
function movePage(fromId: string, toId: string) {
if (fromId === toId) return;
const from = pages.value.findIndex((page) => page.id === fromId);
const to = pages.value.findIndex((page) => page.id === toId);
if (from < 0 || to < 0) return;
const [item] = pages.value.splice(from, 1);
pages.value.splice(to, 0, item);
}
function movePageBy(id: string, delta: number) {
const from = pages.value.findIndex((page) => page.id === id);
if (from < 0) return;
const to = from + delta;
if (to < 0 || to >= pages.value.length) return;
const [item] = pages.value.splice(from, 1);
pages.value.splice(to, 0, item);
}
function onPageDragStart(event: DragEvent, id: string) {
if (busy.value) {
event.preventDefault();
return;
}
dragPageId.value = id;
event.dataTransfer?.setData('text/page-id', id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
function onPageDragOver(event: DragEvent, id: string) {
if (!dragPageId.value || dragPageId.value === id) return;
event.preventDefault();
dropTargetId.value = id;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
function onPageDrop(event: DragEvent, id: string) {
event.preventDefault();
const fromId =
dragPageId.value || event.dataTransfer?.getData('text/page-id') || '';
if (fromId) movePage(fromId, id);
dragPageId.value = null;
dropTargetId.value = null;
}
function onPageDragEnd() {
dragPageId.value = null;
dropTargetId.value = null;
}
function onFileDragOver(event: DragEvent) {
if (busy.value) return;
if (![...((event.dataTransfer?.types as string[]) ?? [])].includes('Files')) {
return;
}
event.preventDefault();
fileDropActive.value = true;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
}
function onFileDragLeave(event: DragEvent) {
const related = event.relatedTarget as Node | null;
if (related && (event.currentTarget as Node).contains(related)) return;
fileDropActive.value = false;
}
function onFileDrop(event: DragEvent) {
event.preventDefault();
fileDropActive.value = false;
if (busy.value) return;
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length) void addFiles(files);
}
function updateCorners(corners: CornerPoints) {
if (!activePage.value) return;
activePage.value.corners = corners;
revokeUrl(activePage.value.warpedUrl);
activePage.value.warpedUrl = null;
activePage.value.ocr = null;
}
function updateAspect(aspect: PaperAspect) {
if (!activePage.value) return;
activePage.value.aspect = aspect;
revokeUrl(activePage.value.warpedUrl);
activePage.value.warpedUrl = null;
activePage.value.ocr = null;
}
function setScanIntensity(value: number) {
if (!activePage.value) return;
activePage.value.scanIntensity = Math.min(
100,
Math.max(0, Math.round(value)),
);
}
async function commitScanIntensity() {
if (!activePage.value || busy.value) return;
await warpActive();
}
async function redetectActive() {
if (!activePage.value) return;
busy.value = true;
try {
await ensureJscanify();
const image = await loadImageFromUrl(activePage.value.sourceUrl);
activePage.value.corners = detectCornersFromImage(image);
revokeUrl(activePage.value.warpedUrl);
activePage.value.warpedUrl = null;
activePage.value.ocr = null;
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : '코너 검출에 실패했습니다.';
} finally {
busy.value = false;
}
}
function useFullFrame() {
if (!activePage.value) return;
activePage.value.corners = fullFrameCorners(
activePage.value.sourceWidth,
activePage.value.sourceHeight,
);
revokeUrl(activePage.value.warpedUrl);
activePage.value.warpedUrl = null;
activePage.value.ocr = null;
}
async function warpActive() {
if (!activePage.value) return;
busy.value = true;
progressLabel.value = '원근 보정 중';
try {
const result = await warpImage(
activePage.value.sourceUrl,
activePage.value.corners,
activePage.value.aspect,
activePage.value.scanIntensity,
);
revokeUrl(activePage.value.warpedUrl);
activePage.value.warpedUrl = result.url;
activePage.value.warpedWidth = result.width;
activePage.value.warpedHeight = result.height;
activePage.value.ocr = null;
notice.value = '보정을 적용했습니다.';
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : '보정에 실패했습니다.';
} finally {
busy.value = false;
progressLabel.value = '';
}
}
async function warpAll() {
busy.value = true;
error.value = '';
try {
for (let i = 0; i < pages.value.length; i += 1) {
const page = pages.value[i];
progress.value = i / pages.value.length;
progressLabel.value = `${page.name} 보정 중`;
const result = await warpImage(
page.sourceUrl,
page.corners,
page.aspect,
page.scanIntensity,
);
revokeUrl(page.warpedUrl);
page.warpedUrl = result.url;
page.warpedWidth = result.width;
page.warpedHeight = result.height;
page.ocr = null;
}
notice.value = '모든 페이지를 보정했습니다.';
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : '일괄 보정에 실패했습니다.';
} finally {
busy.value = false;
progress.value = 0;
progressLabel.value = '';
}
}
async function runOcrOnPage(page: ScannerPage) {
if (!page.warpedUrl) {
const result = await warpImage(
page.sourceUrl,
page.corners,
page.aspect,
page.scanIntensity,
);
revokeUrl(page.warpedUrl);
page.warpedUrl = result.url;
page.warpedWidth = result.width;
page.warpedHeight = result.height;
}
page.ocr = await recognizeImage(page.warpedUrl!, (value, label) => {
progress.value = value;
progressLabel.value = `${page.name}: ${label}`;
});
}
async function runOcrActive() {
if (!activePage.value) return;
busy.value = true;
error.value = '';
try {
await runOcrOnPage(activePage.value);
notice.value = 'OCR을 완료했습니다.';
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : 'OCR에 실패했습니다.';
} finally {
busy.value = false;
progress.value = 0;
progressLabel.value = '';
}
}
async function runOcrAll() {
busy.value = true;
error.value = '';
try {
for (let i = 0; i < pages.value.length; i += 1) {
progressLabel.value = `OCR ${i + 1}/${pages.value.length}`;
await runOcrOnPage(pages.value[i]);
}
notice.value = '모든 페이지 OCR을 완료했습니다.';
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : 'OCR에 실패했습니다.';
} finally {
busy.value = false;
progress.value = 0;
progressLabel.value = '';
}
}
async function blobFromUrl(url: string) {
const response = await fetch(url);
return response.arrayBuffer();
}
async function exportPdf() {
if (!pages.value.length) return;
busy.value = true;
error.value = '';
try {
const inputs = [];
for (let i = 0; i < pages.value.length; i += 1) {
const page = pages.value[i];
progress.value = i / pages.value.length;
progressLabel.value = `${page.name} PDF 준비`;
if (!page.warpedUrl) {
const warped = await warpImage(
page.sourceUrl,
page.corners,
page.aspect,
page.scanIntensity,
);
revokeUrl(page.warpedUrl);
page.warpedUrl = warped.url;
page.warpedWidth = warped.width;
page.warpedHeight = warped.height;
}
if (enableOcr.value && !page.ocr) {
await runOcrOnPage(page);
}
inputs.push({
imageBytes: await blobFromUrl(page.warpedUrl!),
width: page.warpedWidth,
height: page.warpedHeight,
ocr: enableOcr.value ? page.ocr : null,
});
}
progressLabel.value = 'PDF 생성 중';
const bytes = await buildSearchablePdf(inputs);
downloadBlob(
new Blob([Uint8Array.from(bytes)], { type: 'application/pdf' }),
`${baseName()}.pdf`,
);
notice.value = enableOcr.value
? '검색 가능한 OCR PDF를 저장했습니다.'
: '스캔 PDF를 저장했습니다.';
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : 'PDF 저장에 실패했습니다.';
} finally {
busy.value = false;
progress.value = 0;
progressLabel.value = '';
}
}
async function exportActivePng() {
if (!activePage.value) return;
if (!activePage.value.warpedUrl) await warpActive();
if (!activePage.value?.warpedUrl) return;
const response = await fetch(activePage.value.warpedUrl);
downloadBlob(await response.blob(), `${baseName(activePage.value.name)}.png`);
}
function baseName(name = filename.value) {
return name.replace(/\.[^.]+$/, '') || 'scan';
}
function onClose() {
void router.push({ name: 'home' });
}
const progressPercent = computed(() =>
Math.round(Math.min(1, Math.max(0, progress.value)) * 100),
);
</script>
<template>
<AppShell :editor="true">
<div class="scan-page">
<EditorToolbar
:filename="filename"
:show-save="pages.length > 0"
:busy="busy"
@open="onOpen"
@save="exportPdf"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,.png,.jpg,.jpeg,.webp,.pdf,application/pdf"
multiple
@change="onFilesSelected"
/>
<input
ref="cameraInput"
class="sr-only"
type="file"
accept="image/*"
capture="environment"
@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>
<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="onAddFiles">
파일 추가
</button>
<button
type="button"
class="queue__btn-primary"
:disabled="busy"
@click="onCamera"
>
카메라
</button>
</div>
<ul class="queue__list">
<li
v-for="(page, index) in pages"
:key="page.id"
:class="{
active: page.id === activeId,
'is-drop-target': dropTargetId === page.id,
'is-dragging': dragPageId === page.id,
}"
draggable="true"
@dragstart="onPageDragStart($event, page.id)"
@dragover="onPageDragOver($event, page.id)"
@drop="onPageDrop($event, page.id)"
@dragend="onPageDragEnd"
>
<button type="button" class="queue__item" @click="selectPage(page.id)">
<img
class="queue__thumb"
:src="page.warpedUrl || page.sourceUrl"
alt=""
/>
<strong>{{ index + 1 }}. {{ page.name }}</strong>
<span>
{{ page.warpedUrl ? '보정됨' : '원본' }}
<template v-if="page.ocr"> · OCR</template>
</span>
</button>
<div class="queue__controls">
<button
type="button"
class="queue__move"
:disabled="busy || index === 0"
aria-label="앞으로 이동"
@click="movePageBy(page.id, -1)"
>
↑
</button>
<button
type="button"
class="queue__move"
:disabled="busy || index === pages.length - 1"
aria-label="뒤로 이동"
@click="movePageBy(page.id, 1)"
>
↓
</button>
<button
type="button"
class="queue__remove"
:disabled="busy"
@click="removePage(page.id)"
>
삭제
</button>
</div>
</li>
</ul>
<div
class="queue__drop"
:class="{ 'is-active': fileDropActive }"
@dragenter="onFileDragOver"
@dragover="onFileDragOver"
@dragleave="onFileDragLeave"
@drop="onFileDrop"
>
여기에 놓아 추가
</div>
</aside>
<div v-if="pages.length" class="queue-bar">
<button type="button" class="queue-bar__open" @click="openQueue">
<span class="queue-bar__main">
페이지 {{ pages.length }}장
<template v-if="activePageIndex >= 0">
· {{ activePageIndex + 1 }}번
</template>
</span>
<span class="queue-bar__sub">{{ activePage?.name || '목록 보기' }}</span>
<span class="queue-bar__action">목록</span>
</button>
</div>
<Teleport to="body">
<div
v-if="queueOpen"
class="queue-sheet"
role="dialog"
aria-modal="true"
aria-label="페이지 목록"
>
<header class="queue-sheet__header">
<div>
<h2>페이지 목록</h2>
<p>{{ pages.length }}장</p>
</div>
<button type="button" class="queue-sheet__close" @click="closeQueue">
닫기
</button>
</header>
<div class="queue-sheet__actions">
<button type="button" :disabled="busy" @click="onAddFiles">
파일 추가
</button>
<button
type="button"
class="queue__btn-primary"
:disabled="busy"
@click="onCamera"
>
카메라
</button>
</div>
<ul class="queue-sheet__list">
<li
v-for="(page, index) in pages"
:key="`sheet-${page.id}`"
:class="{ active: page.id === activeId }"
>
<button
type="button"
class="queue-sheet__item"
@click="selectPage(page.id)"
>
<img
class="queue-sheet__thumb"
:src="page.warpedUrl || page.sourceUrl"
alt=""
/>
<span class="queue-sheet__meta">
<strong>{{ index + 1 }}. {{ page.name }}</strong>
<small>
{{ page.warpedUrl ? '보정됨' : '원본' }}
<template v-if="page.ocr"> · OCR</template>
</small>
</span>
</button>
<div class="queue-sheet__controls">
<button
type="button"
:disabled="busy || index === 0"
aria-label="앞으로 이동"
@click="movePageBy(page.id, -1)"
>
↑
</button>
<button
type="button"
:disabled="busy || index === pages.length - 1"
aria-label="뒤로 이동"
@click="movePageBy(page.id, 1)"
>
↓
</button>
<button
type="button"
:disabled="busy"
@click="removePage(page.id)"
>
삭제
</button>
</div>
</li>
</ul>
</div>
</Teleport>
<main
class="workspace"
@dragenter="onFileDragOver"
@dragover="onFileDragOver"
@dragleave="onFileDragLeave"
@drop="onFileDrop"
>
<div v-if="!activePage" class="empty" :class="{ 'is-drop-active': fileDropActive }">
<strong>문서 스캐너</strong>
<span class="empty__copy empty__copy--desktop">
이미지 또는 PDF를 올리면 페이지를 목록에 추가합니다. 드래그로 순서를
바꾼 뒤 보정·OCR·검색 가능 PDF로 저장할 수 있습니다. 처리는 모두
브라우저에서 수행됩니다.
</span>
<span class="empty__copy empty__copy--mobile">
사진·PDF를 열고 보정·OCR 후 검색 가능 PDF로 저장하세요. 모두
기기에서 처리됩니다.
</span>
<div class="empty__actions">
<button
type="button"
class="empty__btn-primary"
:disabled="!engineReady || busy"
@click="onCamera"
>
카메라 촬영
</button>
<button type="button" :disabled="!engineReady || busy" @click="onOpen">
파일 열기
</button>
</div>
</div>
<template v-else>
<div class="toolbar-row toolbar-row--primary">
<button type="button" class="btn-accent" :disabled="busy" @click="warpActive">
이 장 보정
</button>
<button type="button" class="btn-accent" :disabled="busy" @click="runOcrActive">
이 장 OCR
</button>
<button
type="button"
class="toolbar-desktop-only"
:disabled="busy"
@click="redetectActive"
>
자동 코너 인식
</button>
<button
type="button"
class="toolbar-desktop-only"
:disabled="busy"
@click="useFullFrame"
>
전체 이미지
</button>
<button
type="button"
class="toolbar-desktop-only"
:disabled="busy"
@click="warpAll"
>
모두 보정
</button>
<button
type="button"
class="toolbar-desktop-only"
:disabled="busy"
@click="runOcrAll"
>
모두 OCR
</button>
<button
type="button"
class="toolbar-desktop-only"
:disabled="busy"
@click="void exportActivePng()"
>
PNG로 저장
</button>
<button
type="button"
class="toolbar-more-btn"
:disabled="busy"
@click="openSettings"
>
더보기
</button>
</div>
<div class="toolbar-extras">
<label class="settings-field settings-field--inline">
<span>용지</span>
<select
:value="activePage.aspect"
:disabled="busy"
@change="
updateAspect(
($event.target as HTMLSelectElement).value as any,
)
"
>
<option value="a4-portrait">A4 세로</option>
<option value="a4-landscape">A4 가로</option>
<option value="square">정사각</option>
<option value="auto">자동 비율</option>
</select>
</label>
<label class="settings-field settings-field--intensity">
<span class="settings-field__row">
스캔 강도
<em>{{ activePage.scanIntensity }}</em>
</span>
<input
class="settings-range"
type="range"
min="0"
max="100"
step="1"
:value="activePage.scanIntensity"
:disabled="busy"
@input="
setScanIntensity(
Number(($event.target as HTMLInputElement).value),
)
"
@change="void commitScanIntensity()"
/>
</label>
<label class="settings-check">
<input
v-model="enableOcr"
type="checkbox"
:disabled="busy"
/>
PDF에 OCR 텍스트 포함
</label>
</div>
<Teleport to="body">
<div
v-if="settingsOpen"
class="settings-modal"
role="dialog"
aria-modal="true"
aria-label="스캔 설정"
>
<button
type="button"
class="settings-modal__backdrop"
aria-label="설정 닫기"
@click="closeSettings"
/>
<div class="settings-modal__panel">
<header class="settings-modal__header">
<h2>더보기</h2>
<button type="button" @click="closeSettings">닫기</button>
</header>
<div class="settings-modal__body">
<label class="settings-field">
<span>용지</span>
<select
:value="activePage.aspect"
:disabled="busy"
@change="
updateAspect(
($event.target as HTMLSelectElement).value as any,
)
"
>
<option value="a4-portrait">A4 세로</option>
<option value="a4-landscape">A4 가로</option>
<option value="square">정사각</option>
<option value="auto">자동 비율</option>
</select>
</label>
<label class="settings-field">
<span class="settings-field__row">
스캔 강도
<em>{{ activePage.scanIntensity }}</em>
</span>
<input
class="settings-range"
type="range"
min="0"
max="100"
step="1"
:value="activePage.scanIntensity"
:disabled="busy"
@input="
setScanIntensity(
Number(($event.target as HTMLInputElement).value),
)
"
@change="void commitScanIntensity()"
/>
<span class="settings-field__hint">
0 원본 · 중간 대비 · 100 문서형
</span>
</label>
<label class="settings-check">
<input
v-model="enableOcr"
type="checkbox"
:disabled="busy"
/>
PDF에 OCR 텍스트 포함
</label>
<div class="settings-actions">
<button type="button" :disabled="busy" @click="redetectActive">
자동 코너 인식
</button>
<button type="button" :disabled="busy" @click="useFullFrame">
전체 이미지
</button>
<button type="button" :disabled="busy" @click="warpAll">
모두 보정
</button>
<button type="button" :disabled="busy" @click="runOcrAll">
모두 OCR
</button>
<button
type="button"
:disabled="busy"
@click="
closeSettings();
void exportActivePng();
"
>
PNG로 저장
</button>
</div>
</div>
</div>
</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"
role="tab"
:aria-selected="mobilePane === 'edit'"
:class="{ active: mobilePane === 'edit' }"
@click="mobilePane = 'edit'"
>
코너 편집
</button>
<button
type="button"
role="tab"
:aria-selected="mobilePane === 'preview'"
:class="{ active: mobilePane === 'preview' }"
@click="showPreviewPane"
>
미리보기
</button>
</div>
<div class="panes" :data-mobile-pane="mobilePane">
<section class="pane pane--edit">
<h2>코너 편집</h2>
<CornerEditor
:image-url="activePage.sourceUrl"
:corners="activePage.corners"
:disabled="busy"
@update:corners="updateCorners"
/>
</section>
<section class="pane pane--preview">
<h2>보정 미리보기</h2>
<img
v-if="activePage.warpedUrl"
class="preview"
:src="activePage.warpedUrl"
alt="보정된 스캔"
/>
<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>
</div>
</section>
</div>
</template>
<div v-if="busy && progressLabel" class="progress" aria-live="polite">
<span>{{ progressLabel }}</span>
<progress :value="progressPercent" max="100" />
<small>{{ progressPercent }}% · {{ warpedCount }}/{{ pages.length }}</small>
</div>
</main>
</div>
</div>
</AppShell>
</template>
<style scoped>
.scan-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
flex: 1;
min-height: 0;
}
.layout.has-pages {
grid-template-columns: 220px minmax(0, 1fr);
}
.layout.has-pages .queue--rail {
grid-column: 1;
grid-row: 1;
}
.layout.has-pages .workspace {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.queue {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
padding: 0.85rem;
overflow: auto;
border-right: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
}
.queue-bar,
.queue-sheet {
display: none;
}
.queue__actions,
.empty__actions,
.toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.queue__actions button,
.empty__actions button,
.toolbar-row button,
.toolbar-more-btn {
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--canvas);
color: var(--ink);
font: inherit;
cursor: pointer;
min-height: 2.25rem;
}
.queue__btn-primary,
.empty__btn-primary,
.btn-accent {
border-color: var(--accent) !important;
background: var(--accent-soft) !important;
color: var(--ink);
font-weight: 600;
}
.queue__list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.4rem;
}
.queue__list li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem;
align-items: stretch;
}
.queue__list li.is-drop-target .queue__item {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.queue__list li.is-dragging {
opacity: 0.55;
}
.queue__item {
display: grid;
grid-template-columns: 40px 1fr;
grid-template-rows: auto auto;
column-gap: 0.45rem;
row-gap: 0.1rem;
align-items: center;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: inherit;
text-align: left;
font: inherit;
cursor: grab;
min-height: 2.75rem;
}
.queue__thumb {
grid-row: 1 / span 2;
width: 40px;
height: 52px;
object-fit: cover;
border-radius: 3px;
background: #111;
}
.queue__list li.active .queue__item {
border-color: var(--accent);
background: var(--accent-soft);
}
.queue__item strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
}
.queue__item span,
.muted {
color: var(--muted);
font-size: 0.72rem;
}
.queue__controls {
display: grid;
gap: 0.15rem;
align-content: center;
}
.queue__move,
.queue__remove {
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
cursor: pointer;
min-width: 2rem;
min-height: 1.75rem;
padding: 0.15rem;
}
.queue__move:disabled {
opacity: 0.35;
cursor: default;
}
.queue__drop {
margin-top: auto;
padding: 0.75rem 0.5rem;
border: 1px dashed var(--border);
border-radius: 4px;
color: var(--muted);
font-size: 0.75rem;
text-align: center;
transition:
border-color 0.15s ease,
background 0.15s ease,
color 0.15s ease;
}
.queue__drop.is-active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--ink);
}
.workspace {
position: relative;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 0.85rem;
}
.empty {
height: 100%;
display: grid;
place-content: center;
justify-items: center;
gap: 0.7rem;
text-align: center;
color: var(--ink);
border: 1px dashed transparent;
border-radius: 6px;
padding: 1rem;
}
.empty.is-drop-active {
border-color: var(--accent);
background: var(--accent-soft);
}
.empty__copy {
max-width: 36rem;
color: var(--muted);
font-size: 0.9rem;
}
.empty__copy--mobile {
display: none;
}
.empty__actions {
width: min(100%, 22rem);
}
.empty__actions button {
flex: 1;
min-height: 2.75rem;
}
.toolbar-row {
margin-bottom: 0.75rem;
align-items: center;
}
.toolbar-row--primary {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.toolbar-more-btn {
display: none;
margin-left: auto;
}
.toolbar-extras {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem 1rem;
margin: -0.25rem 0 0.85rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.toolbar-extras .settings-field--inline {
grid-template-columns: auto minmax(8rem, 11rem);
align-items: center;
gap: 0.45rem;
}
.toolbar-extras .settings-field--inline select {
min-height: 2.25rem;
}
.toolbar-extras .settings-field--intensity {
flex: 1 1 14rem;
min-width: 12rem;
max-width: 22rem;
}
.toolbar-extras .settings-check {
min-height: 2.25rem;
}
.settings-modal {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: end center;
padding: 0;
}
.settings-modal__backdrop {
position: absolute;
inset: 0;
border: 0;
background: color-mix(in srgb, black 45%, transparent);
cursor: pointer;
}
.settings-modal__panel {
position: relative;
z-index: 1;
width: min(100%, 28rem);
max-height: min(88vh, 40rem);
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 14px 14px 0 0;
background: var(--surface);
color: var(--ink);
box-shadow: 0 -8px 28px color-mix(in srgb, black 28%, transparent);
}
.settings-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
}
.settings-modal__header h2 {
margin: 0;
font-size: 1rem;
}
.settings-modal__header button {
min-height: 2.5rem;
padding: 0.35rem 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--canvas);
color: var(--ink);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.settings-modal__body {
overflow: auto;
padding: 0.9rem 1rem 1.25rem;
display: grid;
gap: 0.85rem;
}
.settings-field {
display: grid;
gap: 0.35rem;
font-size: 0.86rem;
}
.settings-field__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.settings-field__row em {
font-style: normal;
font-variant-numeric: tabular-nums;
color: var(--muted, #667);
}
.settings-field__hint {
font-size: 0.75rem;
color: var(--muted, #667);
}
.settings-field select {
min-height: 2.75rem;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 0.55rem;
background: var(--canvas);
color: var(--ink);
font: inherit;
}
.settings-range {
width: 100%;
accent-color: var(--accent);
}
.settings-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.86rem;
}
.settings-actions {
display: grid;
gap: 0.4rem;
}
.settings-actions button {
min-height: 2.6rem;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 0.55rem;
background: var(--canvas);
color: var(--ink);
font: inherit;
cursor: pointer;
text-align: left;
}
.pane-tabs {
display: none;
}
.panes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.pane {
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.pane h2,
.pane h3 {
margin: 0 0 0.65rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.preview {
display: block;
max-width: 100%;
max-height: min(62vh, 720px);
object-fit: contain;
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;
}
.ocr-box pre {
margin: 0;
max-height: 14rem;
overflow: auto;
padding: 0.65rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--canvas);
color: var(--ink);
white-space: pre-wrap;
font-size: 0.78rem;
}
.progress {
position: sticky;
bottom: 0.5rem;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.35rem 0.65rem;
margin-top: 0.75rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border);
border-radius: 5px;
background: color-mix(in srgb, var(--surface) 92%, transparent);
backdrop-filter: blur(8px);
font-size: 0.76rem;
}
.progress progress {
grid-column: 1 / -1;
width: 100%;
accent-color: var(--accent);
}
.viewer-message {
margin: 0;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--accent-soft);
color: var(--ink);
font-size: 0.8rem;
}
.viewer-message--error {
color: var(--danger);
}
.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,
.layout.has-pages {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.layout:not(.has-pages) {
grid-template-rows: minmax(0, 1fr);
}
.layout.has-pages .queue-bar {
grid-column: 1;
grid-row: 1;
}
.layout.has-pages .workspace {
grid-column: 1;
grid-row: 2;
}
.queue--rail {
display: none;
}
.queue-bar {
display: grid;
gap: 0;
padding: 0.65rem 0.75rem 0.55rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.queue-bar__open {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
column-gap: 0.55rem;
row-gap: 0.12rem;
align-items: center;
width: 100%;
min-height: 3rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--canvas);
color: var(--ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.queue-bar__main {
font-size: 0.9rem;
font-weight: 650;
}
.queue-bar__sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 0.74rem;
}
.queue-bar__action {
grid-column: 2;
grid-row: 1 / span 2;
padding: 0.35rem 0.55rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--ink);
font-size: 0.75rem;
font-weight: 600;
}
.queue-sheet {
display: flex;
flex-direction: column;
position: fixed;
inset: 0;
z-index: 80;
background: var(--surface);
color: var(--ink);
}
.queue-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
}
.queue-sheet__header h2 {
margin: 0;
font-size: 1.05rem;
}
.queue-sheet__header p {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.78rem;
}
.queue-sheet__close {
min-height: 2.75rem;
padding: 0.4rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--canvas);
color: var(--ink);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.queue-sheet__actions {
display: flex;
gap: 0.45rem;
padding: 0.75rem 1rem 0.35rem;
}
.queue-sheet__actions button {
flex: 1;
min-height: 2.75rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--canvas);
color: var(--ink);
font: inherit;
cursor: pointer;
}
.queue-sheet__list {
flex: 1;
min-height: 0;
margin: 0;
padding: 0.5rem 1rem 1.25rem;
overflow: auto;
list-style: none;
display: grid;
gap: 0.65rem;
align-content: start;
}
.queue-sheet__list li {
display: grid;
gap: 0.45rem;
padding: 0.55rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--canvas);
}
.queue-sheet__list li.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.queue-sheet__item {
display: grid;
grid-template-columns: 72px 1fr;
gap: 0.7rem;
align-items: center;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.queue-sheet__thumb {
width: 72px;
height: 96px;
object-fit: cover;
border-radius: 6px;
background: #111;
}
.queue-sheet__meta {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.queue-sheet__meta strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.92rem;
}
.queue-sheet__meta small {
color: var(--muted);
font-size: 0.76rem;
}
.queue-sheet__controls {
display: flex;
gap: 0.35rem;
}
.queue-sheet__controls button {
flex: 1;
min-height: 2.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--ink);
font: inherit;
cursor: pointer;
}
.queue-sheet__controls button:disabled {
opacity: 0.4;
cursor: default;
}
.workspace {
padding: 0.65rem;
}
.empty {
place-content: center;
align-content: center;
min-height: min(70vh, 32rem);
padding: 1.25rem 1rem;
}
.empty strong {
font-size: 1.35rem;
}
.empty__copy--desktop {
display: none;
}
.empty__copy--mobile {
display: block;
font-size: 0.92rem;
line-height: 1.45;
}
.empty__actions {
flex-direction: column;
width: min(100%, 20rem);
}
.toolbar-row--primary {
position: sticky;
top: 0;
z-index: 1;
margin: -0.15rem -0.15rem 0.55rem;
padding: 0.45rem 0.15rem;
background: color-mix(in srgb, var(--canvas) 92%, transparent);
backdrop-filter: blur(8px);
}
.toolbar-row--primary button {
flex: 1;
min-height: 2.5rem;
}
.toolbar-desktop-only,
.toolbar-extras {
display: none;
}
.toolbar-more-btn {
display: inline-flex;
align-items: center;
margin-left: 0;
flex: 0 0 auto;
}
.settings-modal {
place-items: end stretch;
}
.settings-modal__panel {
width: 100%;
}
.pane-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.pane-tabs button {
min-height: 2.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--ink);
font: inherit;
cursor: pointer;
}
.pane-tabs button.active {
border-color: var(--accent);
background: var(--accent-soft);
font-weight: 600;
}
.panes {
grid-template-columns: 1fr;
}
.panes[data-mobile-pane='edit'] .pane--preview,
.panes[data-mobile-pane='preview'] .pane--edit {
display: none;
}
.pane h2 {
display: none;
}
.preview {
max-height: min(58vh, 560px);
margin-inline: auto;
}
}
@media (min-width: 901px) {
.pane-tabs {
display: none;
}
.queue__move {
display: none;
}
.toolbar-more-btn,
.settings-modal {
display: none !important;
}
}
</style>