| |
| |
| |
| |
| |
| |
|
|
| import { initEnginePanel } from './components/engine-panel.js'; |
| import { initImageViewer } from './components/image-viewer.js'; |
| import { initTranscriptionPanel } from './components/transcription-panel.js'; |
| import { initBatchPanel } from './components/batch-panel.js'; |
|
|
| |
| export const state = { |
| engines: [], |
| currentEngine: null, |
| engineLoaded: false, |
| imageId: null, |
| imageInfo: null, |
| lines: [], |
| regions: [], |
| isProcessing: false, |
| }; |
|
|
| |
| export const events = new EventTarget(); |
| export function emit(name, detail) { |
| events.dispatchEvent(new CustomEvent(name, { detail })); |
| } |
| export function on(name, fn) { |
| events.addEventListener(name, e => fn(e.detail)); |
| } |
|
|
| |
| export async function api(path, options = {}) { |
| const resp = await fetch(path, { |
| headers: { 'Content-Type': 'application/json', ...options.headers }, |
| ...options, |
| }); |
| if (!resp.ok) { |
| const err = await resp.json().catch(() => ({ detail: resp.statusText })); |
| throw new Error(err.detail || err.message || 'API error'); |
| } |
| return resp; |
| } |
|
|
| |
| export function toast(message, type = 'info', durationMs = 4000) { |
| const container = document.getElementById('toast-container'); |
| const el = document.createElement('div'); |
| el.className = `toast toast-${type}`; |
| el.textContent = message; |
| container.appendChild(el); |
| setTimeout(() => el.remove(), durationMs); |
| } |
|
|
| |
| function shortName(name) { |
| |
| return name |
| .replace('NVIDIA ', '') |
| .replace('GeForce ', '') |
| .replace('Tesla ', '') |
| .replace('Quadro ', ''); |
| } |
|
|
| async function updateGpuStatus() { |
| const widget = document.getElementById('gpu-status'); |
| try { |
| const resp = await api('/api/gpu'); |
| const data = await resp.json(); |
|
|
| if (!data.available || data.gpus.length === 0) { |
| widget.innerHTML = '<span class="gpu-card-name"><span>GPU: N/A</span></span>'; |
| return; |
| } |
|
|
| widget.innerHTML = data.gpus.map(g => { |
| const usedPct = Math.round((g.memory_used_mb / g.memory_total_mb) * 100); |
| const fillClass = usedPct >= 85 ? 'hot' : usedPct >= 60 ? 'warm' : ''; |
| const usedGb = (g.memory_used_mb / 1000).toFixed(1); |
| const totalGb = (g.memory_total_mb / 1000).toFixed(0); |
| const utilHtml = g.utilization_gpu_pct != null |
| ? `<span class="gpu-util-pct">${g.utilization_gpu_pct}%</span>` : ''; |
|
|
| return `<div class="gpu-card"> |
| <div class="gpu-card-name"> |
| <span title="${g.name}">${shortName(g.name)}</span>${utilHtml} |
| </div> |
| <div class="gpu-mem-bar"> |
| <div class="gpu-mem-fill ${fillClass}" style="width:${usedPct}%"></div> |
| </div> |
| <div class="gpu-mem-label">${usedGb}/${totalGb} GB VRAM</div> |
| </div>`; |
| }).join(''); |
| } catch { |
| widget.innerHTML = '<span style="font-size:.75rem;color:var(--text-muted)">GPU: error</span>'; |
| } |
| } |
|
|
| |
| let zoomLevel = 1.0; |
| const ZOOM_STEP = 0.25; |
| let ZOOM_MIN = 0.25; |
| const ZOOM_MAX = 4.0; |
|
|
| function applyZoom(level) { |
| const img = document.getElementById('page-image'); |
| const canvas = document.getElementById('overlay-canvas'); |
| if (!img || !img.naturalWidth) return; |
|
|
| zoomLevel = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level)); |
| const w = Math.round(img.naturalWidth * zoomLevel); |
| const h = Math.round(img.naturalHeight * zoomLevel); |
|
|
| img.style.width = w + 'px'; |
| img.style.height = h + 'px'; |
| canvas.style.width = w + 'px'; |
| canvas.style.height = h + 'px'; |
|
|
| document.getElementById('zoom-level').textContent = |
| Math.round(zoomLevel * 100) + '%'; |
| } |
|
|
| export function fitZoom() { |
| const img = document.getElementById('page-image'); |
| const scroll = document.getElementById('viewer-scroll'); |
| if (!img || !img.naturalWidth || !scroll) return; |
| const scaleW = scroll.clientWidth / img.naturalWidth; |
| const scaleH = scroll.clientHeight / img.naturalHeight; |
| const fit = Math.min(scaleW, scaleH, 1.0); |
| |
| ZOOM_MIN = Math.min(0.25, Math.max(0.05, fit)); |
| applyZoom(fit); |
| } |
|
|
| function initZoomControls() { |
| document.getElementById('btn-zoom-in') .addEventListener('click', () => applyZoom(zoomLevel + ZOOM_STEP)); |
| document.getElementById('btn-zoom-out').addEventListener('click', () => applyZoom(zoomLevel - ZOOM_STEP)); |
| document.getElementById('btn-zoom-fit').addEventListener('click', fitZoom); |
|
|
| |
| document.getElementById('viewer-scroll').addEventListener('wheel', e => { |
| if (!e.ctrlKey && !e.metaKey) return; |
| e.preventDefault(); |
| const factor = e.deltaY < 0 ? 1.10 : 1 / 1.10; |
| applyZoom(zoomLevel * factor); |
| }, { passive: false }); |
|
|
| on('image-uploaded', () => { |
| document.getElementById('zoom-toolbar').classList.remove('hidden'); |
| |
| setTimeout(fitZoom, 80); |
| }); |
|
|
| |
| on('batch-item-start', () => { |
| document.getElementById('zoom-toolbar').classList.remove('hidden'); |
| }); |
| } |
|
|
| |
| const LS_ENGINE = 'polyscriptor_last_engine'; |
| const LS_CONFIG = name => `polyscriptor_config_${name}`; |
|
|
| export function saveEngineConfig(engineName, configObj) { |
| try { |
| localStorage.setItem(LS_ENGINE, engineName); |
| localStorage.setItem(LS_CONFIG(engineName), JSON.stringify(configObj)); |
| } catch { } |
| } |
|
|
| export function loadSavedEngineName() { |
| try { return localStorage.getItem(LS_ENGINE); } catch { return null; } |
| } |
|
|
| export function loadSavedEngineConfig(engineName) { |
| try { |
| const raw = localStorage.getItem(LS_CONFIG(engineName)); |
| return raw ? JSON.parse(raw) : null; |
| } catch { return null; } |
| } |
|
|
| |
| function mobileActivateTab(target) { |
| const tabBtns = document.querySelectorAll('.tab-btn'); |
| const panels = document.querySelectorAll('[data-panel]'); |
| if (!tabBtns.length) return; |
| tabBtns.forEach(b => b.classList.toggle('active', b.dataset.target === target)); |
| panels.forEach(p => p.classList.toggle('panel-active', p.dataset.panel === target)); |
| } |
|
|
| |
| const LS_PANEL_LEFT = 'polyscriptor_panel_left'; |
| const LS_PANEL_RIGHT = 'polyscriptor_panel_right'; |
|
|
| function initResizablePanels() { |
| const app = document.getElementById('app'); |
| const handleLeft = document.getElementById('resize-left'); |
| const handleRight = document.getElementById('resize-right'); |
| if (!handleLeft || !handleRight) return; |
|
|
| |
| const savedLeft = localStorage.getItem(LS_PANEL_LEFT); |
| const savedRight = localStorage.getItem(LS_PANEL_RIGHT); |
| if (savedLeft) document.documentElement.style.setProperty('--panel-left', savedLeft); |
| if (savedRight) document.documentElement.style.setProperty('--panel-right', savedRight); |
|
|
| function startDrag(handle, isLeft) { |
| handle.classList.add('dragging'); |
| document.body.style.cursor = 'col-resize'; |
| document.body.style.userSelect = 'none'; |
|
|
| const onMove = (e) => { |
| const appRect = app.getBoundingClientRect(); |
| const x = (e.touches ? e.touches[0].clientX : e.clientX) - appRect.left; |
| const totalW = appRect.width; |
|
|
| if (isLeft) { |
| const w = Math.max(160, Math.min(x, totalW * 0.4)); |
| const val = Math.round(w) + 'px'; |
| document.documentElement.style.setProperty('--panel-left', val); |
| localStorage.setItem(LS_PANEL_LEFT, val); |
| } else { |
| const w = Math.max(200, Math.min(totalW - x, totalW * 0.5)); |
| const val = Math.round(w) + 'px'; |
| document.documentElement.style.setProperty('--panel-right', val); |
| localStorage.setItem(LS_PANEL_RIGHT, val); |
| } |
| }; |
|
|
| const onUp = () => { |
| handle.classList.remove('dragging'); |
| document.body.style.cursor = ''; |
| document.body.style.userSelect = ''; |
| document.removeEventListener('mousemove', onMove); |
| document.removeEventListener('mouseup', onUp); |
| document.removeEventListener('touchmove', onMove); |
| document.removeEventListener('touchend', onUp); |
| }; |
|
|
| document.addEventListener('mousemove', onMove); |
| document.addEventListener('mouseup', onUp); |
| document.addEventListener('touchmove', onMove, { passive: true }); |
| document.addEventListener('touchend', onUp); |
| } |
|
|
| handleLeft.addEventListener('mousedown', e => { e.preventDefault(); startDrag(handleLeft, true); }); |
| handleRight.addEventListener('mousedown', e => { e.preventDefault(); startDrag(handleRight, false); }); |
| handleLeft.addEventListener('touchstart', e => startDrag(handleLeft, true), { passive: true }); |
| handleRight.addEventListener('touchstart', e => startDrag(handleRight, false), { passive: true }); |
| } |
|
|
| |
| function initKeyboardShortcuts() { |
| document.addEventListener('keydown', e => { |
| |
| const tag = e.target.tagName; |
| const editable = e.target.isContentEditable; |
| if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return; |
|
|
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { |
| e.preventDefault(); |
| document.getElementById('btn-transcribe')?.click(); |
| return; |
| } |
|
|
| |
| if (e.key === 'ArrowLeft') { e.preventDefault(); document.getElementById('btn-nav-prev')?.click(); } |
| if (e.key === 'ArrowRight') { e.preventDefault(); document.getElementById('btn-nav-next')?.click(); } |
| }); |
| } |
|
|
| |
| function initGlobalDropBlocker() { |
| document.addEventListener('dragover', e => e.preventDefault()); |
| document.addEventListener('drop', e => e.preventDefault()); |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| initEnginePanel(); |
| initImageViewer(); |
| initTranscriptionPanel(); |
| initBatchPanel(); |
| initZoomControls(); |
| initResizablePanels(); |
| initKeyboardShortcuts(); |
| initGlobalDropBlocker(); |
| updateGpuStatus(); |
| setInterval(updateGpuStatus, 15000); |
|
|
| |
| on('image-uploaded', () => mobileActivateTab('image')); |
| on('segment-preview', () => mobileActivateTab('image')); |
| on('transcription-start', () => mobileActivateTab('results')); |
| }); |
|
|