mobile ui optimize
This commit is contained in:
1 parent
453fca6152
commit
2beabc610b
6 files changed
+1069
-52
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const imageEl = ref<HTMLImageElement | null>(null);
|
||||
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(
|
||||
() => 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)"
|
||||
/>
|
||||
<div
|
||||
v-if="loupeStyle"
|
||||
class="corner-editor__loupe"
|
||||
:style="loupeStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="corner-editor__loupe-cross" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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<PaddleOcrService> | 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,
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ScannerPage {
|
||||
sourceHeight: number;
|
||||
corners: CornerPoints;
|
||||
aspect: PaperAspect;
|
||||
/** 0 = 원본 색감, 100 = 강한 문서 스캔 보정 */
|
||||
scanIntensity: number;
|
||||
warpedUrl: string | null;
|
||||
warpedWidth: number;
|
||||
warpedHeight: number;
|
||||
|
||||
@@ -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<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(value) =>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user