- 브라우저에서 문서를 스캔·보정하고 OCR이 포함된 PDF로 저장합니다.
+ 브라우저에서 문서를 스캔하고, 배경을 제거하며, 이미지를 크롭·축소·경량화합니다.
diff --git a/packages/frontend/src/views/ToolsImageEditView.vue b/packages/frontend/src/views/ToolsImageEditView.vue
new file mode 100644
index 0000000..6f4441f
--- /dev/null
+++ b/packages/frontend/src/views/ToolsImageEditView.vue
@@ -0,0 +1,860 @@
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
{{ notice }}
+
+
+
+
이미지 편집
+
+ 사진을 열거나 끌어다 놓은 뒤 크롭, 축소, 경량화, 비율 변경을 적용해
+ JPEG·WebP·PNG로 저장합니다. 서버에 업로드하지 않습니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 이미지를 놓아 바꾸기
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/views/ToolsScannerView.vue b/packages/frontend/src/views/ToolsScannerView.vue
index f34560f..10b5ce0 100644
--- a/packages/frontend/src/views/ToolsScannerView.vue
+++ b/packages/frontend/src/views/ToolsScannerView.vue
@@ -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(() =>