mobile ui optimize

This commit is contained in:
ysj7705 committed 2026-07-22 13:35:22 +09:00
1 parent 453fca6152
commit 2beabc610b
6 files changed
+1101 -84

No files matched your search

@@ -163,10 +163,21 @@ defineEmits<{
@media (max-width: 640px) { @media (max-width: 640px) {
.toolbar { .toolbar {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-height: 48px;
padding: 0.4rem 0.55rem;
} }
.toolbar__title { .toolbar__title {
display: none; display: none;
} }
.toolbar__link {
display: none;
}
.toolbar__btn {
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
}
} }
</style> </style>
@@ -12,10 +12,24 @@ const emit = defineEmits<{
'update:corners': [value: CornerPoints]; '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<HTMLElement | null>(null); const host = ref<HTMLElement | null>(null);
const imageEl = ref<HTMLImageElement | null>(null); const imageEl = ref<HTMLImageElement | null>(null);
const display = ref({ width: 0, height: 0, naturalWidth: 1, naturalHeight: 1 }); const display = ref({ width: 0, height: 0, naturalWidth: 1, naturalHeight: 1 });
let dragging: keyof CornerPoints | null = null; const dragging = ref<keyof CornerPoints | null>(null);
const scaleX = computed( const scaleX = computed(
() => display.value.width / Math.max(1, display.value.naturalWidth), () => display.value.width / Math.max(1, display.value.naturalWidth),
@@ -50,6 +64,30 @@ const polygonPoints = computed(() => {
.join(' '); .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() { function measure() {
if (!host.value || !imageEl.value) return; if (!host.value || !imageEl.value) return;
const rect = imageEl.value.getBoundingClientRect(); const rect = imageEl.value.getBoundingClientRect();
@@ -70,13 +108,13 @@ function clamp(point: Point2D): Point2D {
function onPointerDown(key: keyof CornerPoints, event: PointerEvent) { function onPointerDown(key: keyof CornerPoints, event: PointerEvent) {
if (props.disabled) return; if (props.disabled) return;
dragging = key; dragging.value = key;
(event.target as HTMLElement).setPointerCapture?.(event.pointerId); (event.target as HTMLElement).setPointerCapture?.(event.pointerId);
event.preventDefault(); event.preventDefault();
} }
function onPointerMove(event: PointerEvent) { function onPointerMove(event: PointerEvent) {
if (!dragging || !imageEl.value) return; if (!dragging.value || !imageEl.value) return;
const rect = imageEl.value.getBoundingClientRect(); const rect = imageEl.value.getBoundingClientRect();
const nextPoint = clamp({ const nextPoint = clamp({
x: (event.clientX - rect.left) / scaleX.value, x: (event.clientX - rect.left) / scaleX.value,
@@ -84,12 +122,12 @@ function onPointerMove(event: PointerEvent) {
}); });
emit('update:corners', { emit('update:corners', {
...props.corners, ...props.corners,
[dragging]: nextPoint, [dragging.value]: nextPoint,
}); });
} }
function onPointerUp() { function onPointerUp() {
dragging = null; dragging.value = null;
} }
onMounted(() => { onMounted(() => {
@@ -132,11 +170,20 @@ watch(
:key="handle.key" :key="handle.key"
type="button" type="button"
class="corner-editor__handle" class="corner-editor__handle"
:class="{ 'is-active': dragging === handle.key }"
:style="{ left: `${handle.left}px`, top: `${handle.top}px` }" :style="{ left: `${handle.left}px`, top: `${handle.top}px` }"
:disabled="disabled" :disabled="disabled"
:aria-label="handle.key" :aria-label="handle.key"
@pointerdown="onPointerDown(handle.key, $event)" @pointerdown="onPointerDown(handle.key, $event)"
/> />
<div
v-if="loupeStyle"
class="corner-editor__loupe"
:style="loupeStyle"
aria-hidden="true"
>
<span class="corner-editor__loupe-cross" />
</div>
</div> </div>
</template> </template>
@@ -185,7 +232,8 @@ watch(
touch-action: none; touch-action: none;
} }
.corner-editor__handle:active { .corner-editor__handle.is-active {
transform: scale(1.15);
cursor: grabbing; cursor: grabbing;
} }
@@ -193,4 +241,60 @@ watch(
opacity: 0.5; opacity: 0.5;
cursor: default; 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;
}
}
</style> </style>
@@ -1,11 +1,12 @@
import * as ort from 'onnxruntime-web'; 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 { import {
PaddleOcrService, PaddleOcrService,
V5_KOREAN_MOBILE_MODEL, V5_KOREAN_MOBILE_MODEL,
} from 'ppu-paddle-ocr/web'; } from 'ppu-paddle-ocr/web';
import type { OcrResult, OcrWord } from './types'; import type { OcrResult, OcrWord } from './types';
const ORT_WASM_PATH = '/viewer-assets/scanner/ort/';
const PADDLE_BASE = '/viewer-assets/scanner/paddle'; const PADDLE_BASE = '/viewer-assets/scanner/paddle';
let servicePromise: Promise<PaddleOcrService> | null = null; let servicePromise: Promise<PaddleOcrService> | null = null;
@@ -44,7 +45,12 @@ async function getService(
if (!servicePromise) { if (!servicePromise) {
servicePromise = (async () => { servicePromise = (async () => {
onProgress?.(0.05, 'OCR 모델 준비 중'); 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({ const service = new PaddleOcrService({
model: { model: {
...V5_KOREAN_MOBILE_MODEL, ...V5_KOREAN_MOBILE_MODEL,
@@ -32,6 +32,8 @@ export interface ScannerPage {
sourceHeight: number; sourceHeight: number;
corners: CornerPoints; corners: CornerPoints;
aspect: PaperAspect; aspect: PaperAspect;
/** 0 = 원본 색감, 100 = 강한 문서 스캔 보정 */
scanIntensity: number;
warpedUrl: string | null; warpedUrl: string | null;
warpedWidth: number; warpedWidth: number;
warpedHeight: number; warpedHeight: number;
@@ -5,6 +5,69 @@ import {
} from './detect'; } from './detect';
import type { CornerPoints, PaperAspect } from './types'; 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( export function outputSizeForAspect(
aspect: PaperAspect, aspect: PaperAspect,
corners: CornerPoints, corners: CornerPoints,
@@ -42,6 +105,7 @@ export async function warpImage(
sourceUrl: string, sourceUrl: string,
corners: CornerPoints, corners: CornerPoints,
aspect: PaperAspect, aspect: PaperAspect,
scanIntensity: number = DEFAULT_SCAN_INTENSITY,
) { ) {
await ensureJscanify(); await ensureJscanify();
const image = await loadImageFromUrl(sourceUrl); const image = await loadImageFromUrl(sourceUrl);
@@ -56,6 +120,7 @@ export async function warpImage(
if (!canvas) { if (!canvas) {
throw new Error('문서 영역을 추출하지 못했습니다.'); throw new Error('문서 영역을 추출하지 못했습니다.');
} }
applyScanIntensity(canvas, scanIntensity);
const blob = await new Promise<Blob>((resolve, reject) => { const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(value) => (value) =>
File diff suppressed because it is too large. Load diff