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) {
|
@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
Reference in new issue
Block a user