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 }}

-
-