From 2beabc610b9a5d4fb8cd89180b12267b5e124039 Mon Sep 17 00:00:00 2001
From: Sejong Yoo
Date: Wed, 22 Jul 2026 13:35:22 +0900
Subject: [PATCH] mobile ui optimize
---
.../frontend/src/components/EditorToolbar.vue | 11 +
.../src/components/scanner/CornerEditor.vue | 116 ++-
.../frontend/src/integrations/scanner/ocr.ts | 10 +-
.../src/integrations/scanner/types.ts | 2 +
.../frontend/src/integrations/scanner/warp.ts | 65 ++
.../frontend/src/views/ToolsScannerView.vue | 981 ++++++++++++++++--
6 files changed, 1101 insertions(+), 84 deletions(-)
diff --git a/packages/frontend/src/components/EditorToolbar.vue b/packages/frontend/src/components/EditorToolbar.vue
index 267d9e7..5abd9a1 100644
--- a/packages/frontend/src/components/EditorToolbar.vue
+++ b/packages/frontend/src/components/EditorToolbar.vue
@@ -163,10 +163,21 @@ defineEmits<{
@media (max-width: 640px) {
.toolbar {
grid-template-columns: minmax(0, 1fr) auto;
+ min-height: 48px;
+ padding: 0.4rem 0.55rem;
}
.toolbar__title {
display: none;
}
+
+ .toolbar__link {
+ display: none;
+ }
+
+ .toolbar__btn {
+ min-height: 2.5rem;
+ padding: 0.4rem 0.75rem;
+ }
}
diff --git a/packages/frontend/src/components/scanner/CornerEditor.vue b/packages/frontend/src/components/scanner/CornerEditor.vue
index a297297..1aaf49d 100644
--- a/packages/frontend/src/components/scanner/CornerEditor.vue
+++ b/packages/frontend/src/components/scanner/CornerEditor.vue
@@ -12,10 +12,24 @@ const emit = defineEmits<{
'update:corners': [value: CornerPoints];
}>();
+const LOUPE_SIZE = 128;
+const LOUPE_ZOOM = 2.8;
+
+/** 드래그 중인 코너 반대편에 루페를 두어 손가락에 가리지 않게 함 */
+const LOUPE_CORNER: Record<
+ keyof CornerPoints,
+ { x: 'left' | 'right'; y: 'top' | 'bottom' }
+> = {
+ topLeftCorner: { x: 'right', y: 'bottom' },
+ topRightCorner: { x: 'left', y: 'bottom' },
+ bottomRightCorner: { x: 'left', y: 'top' },
+ bottomLeftCorner: { x: 'right', y: 'top' },
+};
+
const host = ref(null);
const imageEl = ref(null);
const display = ref({ width: 0, height: 0, naturalWidth: 1, naturalHeight: 1 });
-let dragging: keyof CornerPoints | null = null;
+const dragging = ref(null);
const scaleX = computed(
() => display.value.width / Math.max(1, display.value.naturalWidth),
@@ -50,6 +64,30 @@ const polygonPoints = computed(() => {
.join(' ');
});
+const loupeStyle = computed(() => {
+ const key = dragging.value;
+ if (!key || display.value.width < 8) return null;
+
+ const point = props.corners[key];
+ const anchor = LOUPE_CORNER[key];
+ const margin = 10;
+ const left =
+ anchor.x === 'left' ? margin : display.value.width - LOUPE_SIZE - margin;
+ const top =
+ anchor.y === 'top' ? margin : display.value.height - LOUPE_SIZE - margin;
+
+ return {
+ width: `${LOUPE_SIZE}px`,
+ height: `${LOUPE_SIZE}px`,
+ left: `${left}px`,
+ top: `${top}px`,
+ backgroundImage: `url(${props.imageUrl})`,
+ backgroundRepeat: 'no-repeat',
+ backgroundSize: `${display.value.naturalWidth * LOUPE_ZOOM}px ${display.value.naturalHeight * LOUPE_ZOOM}px`,
+ backgroundPosition: `${LOUPE_SIZE / 2 - point.x * LOUPE_ZOOM}px ${LOUPE_SIZE / 2 - point.y * LOUPE_ZOOM}px`,
+ };
+});
+
function measure() {
if (!host.value || !imageEl.value) return;
const rect = imageEl.value.getBoundingClientRect();
@@ -70,13 +108,13 @@ function clamp(point: Point2D): Point2D {
function onPointerDown(key: keyof CornerPoints, event: PointerEvent) {
if (props.disabled) return;
- dragging = key;
+ dragging.value = key;
(event.target as HTMLElement).setPointerCapture?.(event.pointerId);
event.preventDefault();
}
function onPointerMove(event: PointerEvent) {
- if (!dragging || !imageEl.value) return;
+ if (!dragging.value || !imageEl.value) return;
const rect = imageEl.value.getBoundingClientRect();
const nextPoint = clamp({
x: (event.clientX - rect.left) / scaleX.value,
@@ -84,12 +122,12 @@ function onPointerMove(event: PointerEvent) {
});
emit('update:corners', {
...props.corners,
- [dragging]: nextPoint,
+ [dragging.value]: nextPoint,
});
}
function onPointerUp() {
- dragging = null;
+ dragging.value = null;
}
onMounted(() => {
@@ -132,11 +170,20 @@ watch(
:key="handle.key"
type="button"
class="corner-editor__handle"
+ :class="{ 'is-active': dragging === handle.key }"
:style="{ left: `${handle.left}px`, top: `${handle.top}px` }"
:disabled="disabled"
:aria-label="handle.key"
@pointerdown="onPointerDown(handle.key, $event)"
/>
+
+
+
@@ -185,7 +232,8 @@ watch(
touch-action: none;
}
-.corner-editor__handle:active {
+.corner-editor__handle.is-active {
+ transform: scale(1.15);
cursor: grabbing;
}
@@ -193,4 +241,60 @@ watch(
opacity: 0.5;
cursor: default;
}
+
+.corner-editor__loupe {
+ position: absolute;
+ z-index: 3;
+ box-sizing: border-box;
+ border: 2px solid white;
+ border-radius: 50%;
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, black 35%, transparent),
+ 0 10px 28px color-mix(in srgb, black 28%, transparent);
+ pointer-events: none;
+ overflow: hidden;
+ background-color: color-mix(in srgb, var(--bg, #111) 80%, black);
+}
+
+.corner-editor__loupe-cross {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.corner-editor__loupe-cross::before,
+.corner-editor__loupe-cross::after {
+ content: '';
+ position: absolute;
+ background: color-mix(in srgb, white 88%, transparent);
+ box-shadow: 0 0 0 0.5px color-mix(in srgb, black 40%, transparent);
+}
+
+.corner-editor__loupe-cross::before {
+ left: 50%;
+ top: 18%;
+ bottom: 18%;
+ width: 1.5px;
+ transform: translateX(-50%);
+}
+
+.corner-editor__loupe-cross::after {
+ top: 50%;
+ left: 18%;
+ right: 18%;
+ height: 1.5px;
+ transform: translateY(-50%);
+}
+
+@media (max-width: 900px) {
+ .corner-editor__image {
+ max-height: min(52vh, 520px);
+ }
+
+ .corner-editor__handle {
+ width: 28px;
+ height: 28px;
+ margin: -14px 0 0 -14px;
+ }
+}
diff --git a/packages/frontend/src/integrations/scanner/ocr.ts b/packages/frontend/src/integrations/scanner/ocr.ts
index 16dc253..21f1a34 100644
--- a/packages/frontend/src/integrations/scanner/ocr.ts
+++ b/packages/frontend/src/integrations/scanner/ocr.ts
@@ -1,11 +1,12 @@
import * as ort from 'onnxruntime-web';
+import ortWasmUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm?url';
+import ortMjsUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs?url';
import {
PaddleOcrService,
V5_KOREAN_MOBILE_MODEL,
} from 'ppu-paddle-ocr/web';
import type { OcrResult, OcrWord } from './types';
-const ORT_WASM_PATH = '/viewer-assets/scanner/ort/';
const PADDLE_BASE = '/viewer-assets/scanner/paddle';
let servicePromise: Promise | null = null;
@@ -44,7 +45,12 @@ async function getService(
if (!servicePromise) {
servicePromise = (async () => {
onProgress?.(0.05, 'OCR 모델 준비 중');
- ort.env.wasm.wasmPaths = ORT_WASM_PATH;
+ // Vite는 /public .mjs 를 import() 하면 실패하므로 node_modules 에셋 URL 사용
+ ort.env.wasm.numThreads = 1;
+ ort.env.wasm.wasmPaths = {
+ wasm: ortWasmUrl,
+ mjs: ortMjsUrl,
+ };
const service = new PaddleOcrService({
model: {
...V5_KOREAN_MOBILE_MODEL,
diff --git a/packages/frontend/src/integrations/scanner/types.ts b/packages/frontend/src/integrations/scanner/types.ts
index 6fa9a26..47ad5fe 100644
--- a/packages/frontend/src/integrations/scanner/types.ts
+++ b/packages/frontend/src/integrations/scanner/types.ts
@@ -32,6 +32,8 @@ export interface ScannerPage {
sourceHeight: number;
corners: CornerPoints;
aspect: PaperAspect;
+ /** 0 = 원본 색감, 100 = 강한 문서 스캔 보정 */
+ scanIntensity: number;
warpedUrl: string | null;
warpedWidth: number;
warpedHeight: number;
diff --git a/packages/frontend/src/integrations/scanner/warp.ts b/packages/frontend/src/integrations/scanner/warp.ts
index e77b932..5ec196f 100644
--- a/packages/frontend/src/integrations/scanner/warp.ts
+++ b/packages/frontend/src/integrations/scanner/warp.ts
@@ -5,6 +5,69 @@ import {
} from './detect';
import type { CornerPoints, PaperAspect } from './types';
+export const DEFAULT_SCAN_INTENSITY = 55;
+
+function clampByte(value: number) {
+ return Math.min(255, Math.max(0, Math.round(value)));
+}
+
+/** 스캔 강도(0–100): 대비·배경 미백·문서형 흑백 혼합 */
+export function applyScanIntensity(
+ canvas: HTMLCanvasElement,
+ intensity: number,
+) {
+ const amount = Math.min(100, Math.max(0, intensity)) / 100;
+ if (amount <= 0.001) return canvas;
+
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (!ctx) return canvas;
+
+ const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const { data } = image;
+ const contrast = 1 + amount * 1.45;
+ const grayMix = amount * 0.9;
+ const whitePush = amount * 0.42;
+ const inkMix = Math.max(0, (amount - 0.55) / 0.45);
+
+ for (let i = 0; i < data.length; i += 4) {
+ let r = data[i]!;
+ let g = data[i + 1]!;
+ let b = data[i + 2]!;
+ const y = 0.299 * r + 0.587 * g + 0.114 * b;
+ r = r * (1 - grayMix) + y * grayMix;
+ g = g * (1 - grayMix) + y * grayMix;
+ b = b * (1 - grayMix) + y * grayMix;
+
+ r = (r - 128) * contrast + 128;
+ g = (g - 128) * contrast + 128;
+ b = (b - 128) * contrast + 128;
+
+ if (whitePush > 0) {
+ const lift = (v: number) =>
+ v > 155 ? v + (255 - v) * whitePush * ((v - 155) / 100) : v;
+ r = lift(r);
+ g = lift(g);
+ b = lift(b);
+ }
+
+ if (inkMix > 0) {
+ const y2 = 0.299 * r + 0.587 * g + 0.114 * b;
+ const ink = Math.max(0, Math.min(1, (175 - y2) / 85));
+ const doc = 255 * (1 - ink) + 18 * ink;
+ r = r * (1 - inkMix) + doc * inkMix;
+ g = g * (1 - inkMix) + doc * inkMix;
+ b = b * (1 - inkMix) + doc * inkMix;
+ }
+
+ data[i] = clampByte(r);
+ data[i + 1] = clampByte(g);
+ data[i + 2] = clampByte(b);
+ }
+
+ ctx.putImageData(image, 0, 0);
+ return canvas;
+}
+
export function outputSizeForAspect(
aspect: PaperAspect,
corners: CornerPoints,
@@ -42,6 +105,7 @@ export async function warpImage(
sourceUrl: string,
corners: CornerPoints,
aspect: PaperAspect,
+ scanIntensity: number = DEFAULT_SCAN_INTENSITY,
) {
await ensureJscanify();
const image = await loadImageFromUrl(sourceUrl);
@@ -56,6 +120,7 @@ export async function warpImage(
if (!canvas) {
throw new Error('문서 영역을 추출하지 못했습니다.');
}
+ applyScanIntensity(canvas, scanIntensity);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(value) =>
diff --git a/packages/frontend/src/views/ToolsScannerView.vue b/packages/frontend/src/views/ToolsScannerView.vue
index 87c159b..0976394 100644
--- a/packages/frontend/src/views/ToolsScannerView.vue
+++ b/packages/frontend/src/views/ToolsScannerView.vue
@@ -31,7 +31,7 @@ import type {
PaperAspect,
ScannerPage,
} from '@/integrations/scanner/types';
-import { warpImage } from '@/integrations/scanner/warp';
+import { warpImage, DEFAULT_SCAN_INTENSITY } from '@/integrations/scanner/warp';
import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'ToolsScannerView' });
@@ -52,6 +52,25 @@ const enableOcr = ref(true);
const fileDropActive = ref(false);
const dragPageId = ref(null);
const dropTargetId = ref(null);
+const coarsePointer = ref(false);
+const mobilePane = ref<'edit' | 'preview'>('edit');
+const queueOpen = ref(false);
+const settingsOpen = ref(false);
+const mediaQueryCleanups: Array<() => void> = [];
+
+function refreshPointerMode() {
+ if (typeof window === 'undefined') return;
+ coarsePointer.value =
+ window.matchMedia('(pointer: coarse)').matches ||
+ window.matchMedia('(max-width: 900px)').matches;
+ if (!coarsePointer.value) queueOpen.value = false;
+}
+
+function syncBodyScrollLock() {
+ if (typeof document === 'undefined') return;
+ document.body.style.overflow =
+ queueOpen.value || settingsOpen.value ? 'hidden' : '';
+}
usePageTitle(filename);
@@ -59,6 +78,10 @@ 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,
);
@@ -94,6 +117,7 @@ async function pushPageFromSource(
sourceHeight: h,
corners,
aspect: 'a4-portrait',
+ scanIntensity: DEFAULT_SCAN_INTENSITY,
warpedUrl: null,
warpedWidth: 0,
warpedHeight: 0,
@@ -140,7 +164,9 @@ async function addFiles(files: File[]) {
}
}
- notice.value = `${added}장을 추가했습니다. 드래그로 순서를 바꿀 수 있습니다.`;
+ notice.value = coarsePointer.value
+ ? `${added}장을 추가했습니다. 화살표로 순서를 바꿀 수 있습니다.`
+ : `${added}장을 추가했습니다. 드래그로 순서를 바꿀 수 있습니다.`;
} catch (reason) {
error.value =
reason instanceof Error
@@ -159,6 +185,15 @@ function consumePending() {
}
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;
@@ -175,6 +210,9 @@ onMounted(() => {
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);
@@ -186,6 +224,20 @@ watch(activePage, (page) => {
if (page) filename.value = page.name;
});
+watch([queueOpen, settingsOpen], () => {
+ syncBodyScrollLock();
+});
+
+watch(
+ () => pages.value.length,
+ (count) => {
+ if (!count) {
+ queueOpen.value = false;
+ settingsOpen.value = false;
+ }
+ },
+);
+
function onOpen() {
fileInput.value?.click();
}
@@ -203,6 +255,23 @@ function onFilesSelected(event: Event) {
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;
}
function removePage(id: string) {
@@ -225,6 +294,15 @@ function movePage(fromId: string, toId: string) {
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();
@@ -298,6 +376,16 @@ function updateAspect(aspect: PaperAspect) {
activePage.value.ocr = null;
}
+function updateScanIntensity(value: number) {
+ if (!activePage.value) return;
+ const next = Math.min(100, Math.max(0, Math.round(value)));
+ if (activePage.value.scanIntensity === next) return;
+ activePage.value.scanIntensity = next;
+ revokeUrl(activePage.value.warpedUrl);
+ activePage.value.warpedUrl = null;
+ activePage.value.ocr = null;
+}
+
async function redetectActive() {
if (!activePage.value) return;
busy.value = true;
@@ -336,6 +424,7 @@ async function warpActive() {
activePage.value.sourceUrl,
activePage.value.corners,
activePage.value.aspect,
+ activePage.value.scanIntensity,
);
revokeUrl(activePage.value.warpedUrl);
activePage.value.warpedUrl = result.url;
@@ -360,7 +449,12 @@ async function warpAll() {
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);
+ const result = await warpImage(
+ page.sourceUrl,
+ page.corners,
+ page.aspect,
+ page.scanIntensity,
+ );
revokeUrl(page.warpedUrl);
page.warpedUrl = result.url;
page.warpedWidth = result.width;
@@ -380,7 +474,12 @@ async function warpAll() {
async function runOcrOnPage(page: ScannerPage) {
if (!page.warpedUrl) {
- const result = await warpImage(page.sourceUrl, page.corners, page.aspect);
+ const result = await warpImage(
+ page.sourceUrl,
+ page.corners,
+ page.aspect,
+ page.scanIntensity,
+ );
revokeUrl(page.warpedUrl);
page.warpedUrl = result.url;
page.warpedWidth = result.width;
@@ -448,6 +547,7 @@ async function exportPdf() {
page.sourceUrl,
page.corners,
page.aspect,
+ page.scanIntensity,
);
revokeUrl(page.warpedUrl);
page.warpedUrl = warped.url;
@@ -538,17 +638,22 @@ const progressPercent = computed(() =>
{{ notice }}
-