|
|
|
|
@@ -2,9 +2,11 @@
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
|
|
|
|
<title>finrod — translator</title>
|
|
|
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cg fill='%23d4a566'%3E%3Cpath d='M16 1 L18 14 L31 16 L18 18 L16 31 L14 18 L1 16 L14 14 Z'/%3E%3Cpath d='M16 4 L22 16 L16 28 L10 16 Z' opacity='.55'/%3E%3C/g%3E%3C/svg%3E" />
|
|
|
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=block" />
|
|
|
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&display=swap" />
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
color-scheme: light dark;
|
|
|
|
|
@@ -33,23 +35,119 @@
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--fg);
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 2rem 1rem;
|
|
|
|
|
/* Top padding (~3.5 rem) clears the fixed hamburger button.
|
|
|
|
|
Bottom padding leaves room for the stacked FABs. */
|
|
|
|
|
padding: 3.5rem 1rem calc(9rem + env(safe-area-inset-bottom, 0px)) 1rem;
|
|
|
|
|
}
|
|
|
|
|
.wrap { max-width: 960px; margin: 0 auto; }
|
|
|
|
|
h1 {
|
|
|
|
|
font-weight: 300;
|
|
|
|
|
letter-spacing: 0.05em;
|
|
|
|
|
margin: 0 0 0.25rem 0;
|
|
|
|
|
/* Cinzel wordmark — only used inside the side drawer */
|
|
|
|
|
.drawer-wordmark {
|
|
|
|
|
font-family: 'Cinzel', Georgia, serif;
|
|
|
|
|
font-size: 1.9rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
margin: 0;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
}
|
|
|
|
|
.sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
|
|
|
|
|
.drawer-wordmark svg { width: 26px; height: 26px; }
|
|
|
|
|
.controls {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
.controls { grid-template-columns: 1fr; }
|
|
|
|
|
|
|
|
|
|
/* Hamburger menu (top-left) → slide-out side drawer that houses the
|
|
|
|
|
Model selector (and future per-app settings). */
|
|
|
|
|
.drawer-toggle {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: calc(1rem + env(safe-area-inset-top, 0px));
|
|
|
|
|
left: calc(1rem + env(safe-area-inset-left, 0px));
|
|
|
|
|
width: 40px;
|
|
|
|
|
height: 40px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 0;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
background: var(--panel);
|
|
|
|
|
color: var(--fg);
|
|
|
|
|
z-index: 50;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.drawer-toggle .material-symbols-outlined { font-size: 22px; }
|
|
|
|
|
|
|
|
|
|
.drawer-backdrop {
|
|
|
|
|
position: fixed;
|
|
|
|
|
inset: 0;
|
|
|
|
|
background: rgba(0, 0, 0, 0.55);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
transition: opacity 0.2s;
|
|
|
|
|
z-index: 200;
|
|
|
|
|
}
|
|
|
|
|
.drawer-backdrop.open { opacity: 1; pointer-events: auto; }
|
|
|
|
|
|
|
|
|
|
.drawer {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
width: min(85vw, 320px);
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
border-right: 1px solid var(--border);
|
|
|
|
|
padding:
|
|
|
|
|
max(1rem, env(safe-area-inset-top, 0px))
|
|
|
|
|
1.25rem
|
|
|
|
|
max(1rem, env(safe-area-inset-bottom, 0px))
|
|
|
|
|
max(1.25rem, env(safe-area-inset-left, 0px));
|
|
|
|
|
transform: translateX(-100%);
|
|
|
|
|
transition: transform 0.25s ease;
|
|
|
|
|
z-index: 201;
|
|
|
|
|
box-shadow: 4px 0 14px rgba(0, 0, 0, 0.3);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
}
|
|
|
|
|
.drawer.open { transform: translateX(0); }
|
|
|
|
|
.drawer-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
.drawer-close {
|
|
|
|
|
background: transparent;
|
|
|
|
|
border: none;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
width: 32px;
|
|
|
|
|
height: 32px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 0;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
.drawer-close:hover { color: var(--fg); background: var(--panel); }
|
|
|
|
|
.drawer-close .material-symbols-outlined { font-size: 20px; }
|
|
|
|
|
.drawer label {
|
|
|
|
|
display: block;
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
}
|
|
|
|
|
label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
|
|
|
select, textarea, button {
|
|
|
|
|
@@ -156,32 +254,55 @@
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Input-pane header: label on the left, "Scan image" button on the right */
|
|
|
|
|
.input-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
/* Material Symbols Outlined — used for FAB camera, rotate, reset, check */
|
|
|
|
|
.material-symbols-outlined {
|
|
|
|
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
user-select: none;
|
|
|
|
|
}
|
|
|
|
|
.input-header label { margin-bottom: 0; }
|
|
|
|
|
.scan-btn {
|
|
|
|
|
background: transparent;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
padding: 0.2rem 0.6rem;
|
|
|
|
|
|
|
|
|
|
/* Floating action buttons — stacked at bottom-right.
|
|
|
|
|
Translate is the primary action (closest to the thumb), Scan sits above. */
|
|
|
|
|
.fab {
|
|
|
|
|
position: fixed;
|
|
|
|
|
right: calc(1.5rem + env(safe-area-inset-right, 0px));
|
|
|
|
|
width: 56px;
|
|
|
|
|
height: 56px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 0;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: #fff;
|
|
|
|
|
border: none;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
width: auto;
|
|
|
|
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
|
|
|
|
z-index: 100;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
transition: transform 0.15s, box-shadow 0.15s;
|
|
|
|
|
}
|
|
|
|
|
.scan-btn:hover:not(:disabled) {
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
color: var(--fg);
|
|
|
|
|
.fab .material-symbols-outlined { font-size: 28px; }
|
|
|
|
|
.fab:hover:not(:disabled) { transform: scale(1.06); }
|
|
|
|
|
.fab:disabled { opacity: 0.55; cursor: progress; }
|
|
|
|
|
/* Android Chrome shrinks the layout viewport when the soft keyboard
|
|
|
|
|
opens (via `interactive-widget=resizes-content` on the viewport meta),
|
|
|
|
|
so plain `bottom: 1.5rem` is already above the keyboard. */
|
|
|
|
|
.fab-primary {
|
|
|
|
|
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
|
|
|
|
}
|
|
|
|
|
.fab-secondary {
|
|
|
|
|
/* sits above the primary: 56 (primary height) + 0.75rem gap + base offset */
|
|
|
|
|
bottom: calc(1.5rem + 56px + 0.75rem + env(safe-area-inset-bottom, 0px));
|
|
|
|
|
}
|
|
|
|
|
.fab.busy {
|
|
|
|
|
animation: fab-pulse 1.4s ease-in-out infinite;
|
|
|
|
|
}
|
|
|
|
|
@keyframes fab-pulse {
|
|
|
|
|
0%, 100% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }
|
|
|
|
|
50% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), 0 0 0 8px rgba(224, 122, 62, 0.35); }
|
|
|
|
|
}
|
|
|
|
|
.scan-btn:disabled { cursor: progress; opacity: 0.6; }
|
|
|
|
|
.scan-status {
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
@@ -190,6 +311,154 @@
|
|
|
|
|
}
|
|
|
|
|
.scan-status.error { color: var(--error); }
|
|
|
|
|
|
|
|
|
|
/* Crop / rotate modal (cropperjs lives inside .modal-body) */
|
|
|
|
|
.modal-backdrop {
|
|
|
|
|
position: fixed;
|
|
|
|
|
inset: 0;
|
|
|
|
|
background: rgba(0, 0, 0, 0.78);
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
/* Generous side padding pushes the modal in from Android's edge-swipe
|
|
|
|
|
gesture zone (~24dp from screen edges triggers back/home). */
|
|
|
|
|
padding: 1rem max(1rem, env(safe-area-inset-right, 0px)) 1rem max(1rem, env(safe-area-inset-left, 0px));
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
}
|
|
|
|
|
.modal {
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
width: min(90vw, 900px);
|
|
|
|
|
max-height: 90vh;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
.modal-header {
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
}
|
|
|
|
|
.modal-body {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
max-height: 70vh;
|
|
|
|
|
background: #000;
|
|
|
|
|
/* Inset so cropper's corner handles never sit on a screen-edge gesture zone.
|
|
|
|
|
2 rem + backdrop 1 rem = ~48 px clear from screen edges on a phone. */
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
}
|
|
|
|
|
.modal-body img { display: block; max-width: 100%; }
|
|
|
|
|
.modal-footer {
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
.modal-footer .group {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
.modal-btn {
|
|
|
|
|
background: transparent;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
padding: 0.4rem 0.9rem;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
margin: 0;
|
|
|
|
|
width: auto;
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
}
|
|
|
|
|
.modal-btn .material-symbols-outlined { font-size: 20px; }
|
|
|
|
|
.modal-btn.icon { padding: 0.35rem 0.55rem; min-width: 2.4rem; }
|
|
|
|
|
.modal-btn:hover { border-color: var(--accent); color: var(--fg); }
|
|
|
|
|
.modal-btn.active {
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: #fff;
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
.modal-btn.primary {
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: #fff;
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
.modal-btn.primary.icon {
|
|
|
|
|
padding: 0.55rem 1.1rem;
|
|
|
|
|
min-width: 3.2rem;
|
|
|
|
|
}
|
|
|
|
|
.modal-btn.primary.icon .material-symbols-outlined { font-size: 24px; }
|
|
|
|
|
.modal-btn.primary:hover { color: #fff; }
|
|
|
|
|
|
|
|
|
|
/* Camera viewfinder modal */
|
|
|
|
|
.cam-body {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 0 !important;
|
|
|
|
|
}
|
|
|
|
|
.cam-body video {
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
max-height: 70vh;
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* On mobile: full-bleed modal, buttons hug the screen edges. Body padding
|
|
|
|
|
still ~16 px so cropper corner handles stay outside Android's edge-swipe
|
|
|
|
|
gesture zone — tapping a button doesn't trigger it, but dragging into
|
|
|
|
|
the very edge does. */
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
.modal-backdrop { padding: 0; }
|
|
|
|
|
.modal {
|
|
|
|
|
width: 100vw;
|
|
|
|
|
max-width: 100vw;
|
|
|
|
|
height: 100dvh; /* dynamic viewport — accounts for mobile browser chrome */
|
|
|
|
|
max-height: 100dvh;
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
}
|
|
|
|
|
.modal-header {
|
|
|
|
|
flex-shrink: 0; /* never get squeezed out by the body */
|
|
|
|
|
padding-top: max(0.75rem, env(safe-area-inset-top, 0px));
|
|
|
|
|
}
|
|
|
|
|
.modal-body {
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
padding:
|
|
|
|
|
0.5rem
|
|
|
|
|
max(1rem, env(safe-area-inset-right, 0px))
|
|
|
|
|
0.5rem
|
|
|
|
|
max(1rem, env(safe-area-inset-left, 0px));
|
|
|
|
|
max-height: none;
|
|
|
|
|
}
|
|
|
|
|
.cam-body { padding: 0 !important; }
|
|
|
|
|
.cam-body video {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
max-height: none;
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
}
|
|
|
|
|
.modal-footer {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
background: var(--bg); /* explicit so it always reads clearly */
|
|
|
|
|
padding:
|
|
|
|
|
0.5rem
|
|
|
|
|
max(0.5rem, env(safe-area-inset-right, 0px))
|
|
|
|
|
max(0.5rem, env(safe-area-inset-bottom, 0px))
|
|
|
|
|
max(0.5rem, env(safe-area-inset-left, 0px));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* CodeMirror editor host — matches .output styling so input & output align */
|
|
|
|
|
.cm-editor {
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
@@ -216,10 +485,31 @@
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<button type="button" id="drawer-toggle" class="drawer-toggle" aria-label="Open menu" title="Menu">
|
|
|
|
|
<span class="material-symbols-outlined">menu</span>
|
|
|
|
|
</button>
|
|
|
|
|
<div id="drawer-backdrop" class="drawer-backdrop"></div>
|
|
|
|
|
<aside id="drawer" class="drawer" aria-hidden="true">
|
|
|
|
|
<div class="drawer-header">
|
|
|
|
|
<div class="drawer-wordmark">
|
|
|
|
|
<svg viewBox="0 0 32 32" aria-hidden="true">
|
|
|
|
|
<g fill="currentColor">
|
|
|
|
|
<path d="M16 1 L18 14 L31 16 L18 18 L16 31 L14 18 L1 16 L14 14 Z"/>
|
|
|
|
|
<path d="M16 4 L22 16 L16 28 L10 16 Z" opacity=".55"/>
|
|
|
|
|
</g>
|
|
|
|
|
</svg>
|
|
|
|
|
<span>Finrod</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button type="button" id="drawer-close" class="drawer-close" aria-label="Close menu" title="Close">
|
|
|
|
|
<span class="material-symbols-outlined">close</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label for="model">Model</label>
|
|
|
|
|
<select id="model"></select>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
<div class="wrap">
|
|
|
|
|
<h1>finrod</h1>
|
|
|
|
|
<div class="sub">Translation via Ollama · loremaster of tongues</div>
|
|
|
|
|
|
|
|
|
|
<div class="controls">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="source">From</label>
|
|
|
|
|
@@ -229,10 +519,6 @@
|
|
|
|
|
<label for="target">To</label>
|
|
|
|
|
<select id="target"></select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label for="model">Model</label>
|
|
|
|
|
<select id="model"></select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="hot-status" class="hot-status idle">
|
|
|
|
|
@@ -242,12 +528,9 @@
|
|
|
|
|
|
|
|
|
|
<div class="panes">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="input-header">
|
|
|
|
|
<label for="input">Input</label>
|
|
|
|
|
<button type="button" id="scan-btn" class="scan-btn" title="Extract text from a camera shot or image file">Scan image</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="input"></div>
|
|
|
|
|
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
|
|
|
|
|
<input type="file" id="scan-input" accept="image/*" hidden>
|
|
|
|
|
<div id="scan-status" class="scan-status"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
@@ -256,10 +539,16 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button id="go">Translate</button>
|
|
|
|
|
<div id="meta" class="meta"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button type="button" id="scan-btn" class="fab fab-secondary" aria-label="Scan image (OCR)" title="Scan image (OCR)">
|
|
|
|
|
<span class="material-symbols-outlined">photo_camera</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" id="go" class="fab fab-primary" aria-label="Translate" title="Translate (⌘/Ctrl + Enter)">
|
|
|
|
|
<span class="material-symbols-outlined">translate</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<script type="module">
|
|
|
|
|
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
|
|
|
|
|
import { EditorState } from "https://esm.sh/@codemirror/state@6";
|
|
|
|
|
@@ -329,15 +618,238 @@
|
|
|
|
|
scanStatus.classList.toggle("error", error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Crop + rotate modal. Lazy-loads cropperjs + its CSS from esm.sh on
|
|
|
|
|
// first use. Resolves with a Blob (cropped, rotation baked in) or null
|
|
|
|
|
// if the user cancelled.
|
|
|
|
|
let cropperCssLoaded = false;
|
|
|
|
|
async function openCropper(file) {
|
|
|
|
|
if (!cropperCssLoaded) {
|
|
|
|
|
const link = document.createElement("link");
|
|
|
|
|
link.rel = "stylesheet";
|
|
|
|
|
link.href = "https://esm.sh/cropperjs@1/dist/cropper.min.css";
|
|
|
|
|
document.head.appendChild(link);
|
|
|
|
|
cropperCssLoaded = true;
|
|
|
|
|
}
|
|
|
|
|
const { default: Cropper } = await import("https://esm.sh/cropperjs@1");
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const backdrop = document.createElement("div");
|
|
|
|
|
backdrop.className = "modal-backdrop";
|
|
|
|
|
const modal = document.createElement("div");
|
|
|
|
|
modal.className = "modal";
|
|
|
|
|
const url = URL.createObjectURL(file);
|
|
|
|
|
modal.innerHTML = `
|
|
|
|
|
<div class="modal-header">Drag on the image to select the text region</div>
|
|
|
|
|
<div class="modal-body"><img alt="" /></div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<div class="group">
|
|
|
|
|
<button class="modal-btn icon" data-act="rotL" title="Rotate left"><span class="material-symbols-outlined">rotate_left</span></button>
|
|
|
|
|
<button class="modal-btn icon" data-act="rotR" title="Rotate right"><span class="material-symbols-outlined">rotate_right</span></button>
|
|
|
|
|
<button class="modal-btn icon" data-act="invert" title="Invert colours (for light text on dark background)"><span class="material-symbols-outlined">invert_colors</span></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="group">
|
|
|
|
|
<button class="modal-btn" data-act="cancel">Cancel</button>
|
|
|
|
|
<button class="modal-btn primary icon" data-act="use" title="Use this"><span class="material-symbols-outlined">check</span></button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
backdrop.appendChild(modal);
|
|
|
|
|
document.body.appendChild(backdrop);
|
|
|
|
|
|
|
|
|
|
const img = modal.querySelector("img");
|
|
|
|
|
img.src = url;
|
|
|
|
|
const cropper = new Cropper(img, {
|
|
|
|
|
viewMode: 1,
|
|
|
|
|
// No default crop box — user draws their own by dragging on the image.
|
|
|
|
|
autoCrop: false,
|
|
|
|
|
background: false,
|
|
|
|
|
// Honour EXIF orientation from the phone shot.
|
|
|
|
|
checkOrientation: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let invert = false;
|
|
|
|
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
cropper.destroy();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
backdrop.remove();
|
|
|
|
|
document.removeEventListener("keydown", onKey);
|
|
|
|
|
};
|
|
|
|
|
const finish = (result) => { cleanup(); resolve(result); };
|
|
|
|
|
|
|
|
|
|
const onKey = (e) => {
|
|
|
|
|
if (e.key === "Escape") finish(null);
|
|
|
|
|
else if (e.key === "Enter") emit("use");
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
|
|
|
|
|
|
function emit(act, btn) {
|
|
|
|
|
if (act === "rotL") cropper.rotate(-90);
|
|
|
|
|
else if (act === "rotR") cropper.rotate(90);
|
|
|
|
|
else if (act === "invert") {
|
|
|
|
|
invert = !invert;
|
|
|
|
|
btn?.classList.toggle("active", invert);
|
|
|
|
|
}
|
|
|
|
|
else if (act === "cancel") finish(null);
|
|
|
|
|
else if (act === "use") {
|
|
|
|
|
// With autoCrop:false, if the user never drew a region, force a
|
|
|
|
|
// default crop covering the full image so getCroppedCanvas() works.
|
|
|
|
|
if (!cropper.cropped) cropper.crop();
|
|
|
|
|
const canvas = cropper.getCroppedCanvas({
|
|
|
|
|
maxWidth: OCR_MAX_DIM * 2,
|
|
|
|
|
maxHeight: OCR_MAX_DIM * 2,
|
|
|
|
|
});
|
|
|
|
|
if (canvas) canvas.toBlob(blob => finish({ blob, invert }), "image/png");
|
|
|
|
|
else finish(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modal.addEventListener("click", (e) => {
|
|
|
|
|
const btn = e.target.closest("[data-act]");
|
|
|
|
|
if (btn) emit(btn.dataset.act, btn);
|
|
|
|
|
});
|
|
|
|
|
// Click on backdrop (outside the modal) = cancel
|
|
|
|
|
backdrop.addEventListener("click", (e) => {
|
|
|
|
|
if (e.target === backdrop) finish(null);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Downscale to at most OCR_MAX_DIM on the long edge AND binarise with
|
|
|
|
|
// Bradley's adaptive threshold. Phone photos have gradients, shadows
|
|
|
|
|
// and texture that Tesseract trained on scanned pages doesn't expect,
|
|
|
|
|
// so it hallucinates text. Adaptive thresholding turns the input into
|
|
|
|
|
// a binary "ink vs paper" mask which is what the recogniser wants.
|
|
|
|
|
const OCR_MAX_DIM = 1600;
|
|
|
|
|
const OCR_CONFIDENCE_MIN = 60; // word-level confidence (0-100)
|
|
|
|
|
|
|
|
|
|
async function preprocessForOCR(file, { invert = false } = {}) {
|
|
|
|
|
const bitmap = await createImageBitmap(file);
|
|
|
|
|
const long = Math.max(bitmap.width, bitmap.height);
|
|
|
|
|
const scale = Math.min(1, OCR_MAX_DIM / long);
|
|
|
|
|
const w = Math.round(bitmap.width * scale);
|
|
|
|
|
const h = Math.round(bitmap.height * scale);
|
|
|
|
|
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = w;
|
|
|
|
|
canvas.height = h;
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
|
|
|
|
bitmap.close?.();
|
|
|
|
|
|
|
|
|
|
binarizeAdaptive(ctx, w, h, { invert });
|
|
|
|
|
|
|
|
|
|
// PNG, not JPEG — JPEG would re-blur the freshly-sharp B/W edges.
|
|
|
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
|
|
|
|
return { blob, w, h, scaled: scale < 1, origLong: long };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bradley/Roth adaptive threshold: compare each pixel against the
|
|
|
|
|
// mean of a local window. Computed in O(N) total via an integral
|
|
|
|
|
// image. Output is a 3-channel B/W image (R=G=B=0 or 255).
|
|
|
|
|
// invert=true flips the grayscale before thresholding so white-on-dark
|
|
|
|
|
// text comes out as the expected black-on-white for Tesseract.
|
|
|
|
|
function binarizeAdaptive(ctx, w, h, { invert = false } = {}) {
|
|
|
|
|
const imgData = ctx.getImageData(0, 0, w, h);
|
|
|
|
|
const data = imgData.data;
|
|
|
|
|
|
|
|
|
|
// 1. Grayscale via BT.601 luminance
|
|
|
|
|
const gray = new Uint8Array(w * h);
|
|
|
|
|
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
|
|
|
|
|
gray[j] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0;
|
|
|
|
|
}
|
|
|
|
|
if (invert) {
|
|
|
|
|
for (let i = 0; i < gray.length; i++) gray[i] = 255 - gray[i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Integral image (summed-area table)
|
|
|
|
|
const integral = new Float64Array(w * h);
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
let rowSum = 0;
|
|
|
|
|
const yw = y * w;
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
rowSum += gray[yw + x];
|
|
|
|
|
integral[yw + x] = rowSum + (y > 0 ? integral[yw - w + x] : 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Threshold: pixel becomes black if it's > T% below the local mean
|
|
|
|
|
const winSize = Math.max(15, Math.floor(w / 32)) | 1; // odd window
|
|
|
|
|
const halfWin = (winSize - 1) >> 1;
|
|
|
|
|
const T = 0.15;
|
|
|
|
|
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
const yw = y * w;
|
|
|
|
|
const y1 = Math.max(0, y - halfWin);
|
|
|
|
|
const y2 = Math.min(h - 1, y + halfWin);
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
const x1 = Math.max(0, x - halfWin);
|
|
|
|
|
const x2 = Math.min(w - 1, x + halfWin);
|
|
|
|
|
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
|
|
|
|
|
const A = (x1 > 0 && y1 > 0) ? integral[(y1 - 1) * w + (x1 - 1)] : 0;
|
|
|
|
|
const B = (y1 > 0) ? integral[(y1 - 1) * w + x2] : 0;
|
|
|
|
|
const C = (x1 > 0) ? integral[y2 * w + (x1 - 1)] : 0;
|
|
|
|
|
const D = integral[y2 * w + x2];
|
|
|
|
|
const mean = (D - B - C + A) / count;
|
|
|
|
|
const black = gray[yw + x] < mean * (1 - T) ? 0 : 255;
|
|
|
|
|
const idx = (yw + x) * 4;
|
|
|
|
|
data[idx] = data[idx + 1] = data[idx + 2] = black;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ctx.putImageData(imgData, 0, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drop words below confidence threshold; re-assemble preserving line breaks.
|
|
|
|
|
function filterByConfidence(result, threshold = OCR_CONFIDENCE_MIN) {
|
|
|
|
|
const lines = result.lines || [];
|
|
|
|
|
if (!lines.length) return result.text || "";
|
|
|
|
|
const keptLines = lines.map(line => {
|
|
|
|
|
const words = (line.words || [])
|
|
|
|
|
.filter(w => (w.confidence ?? 100) >= threshold)
|
|
|
|
|
.map(w => w.text);
|
|
|
|
|
return words.length ? words.join(" ") : "";
|
|
|
|
|
});
|
|
|
|
|
// Collapse consecutive blank lines so dropped runs don't leave gaps.
|
|
|
|
|
return keptLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runOCR(file) {
|
|
|
|
|
const src = sourceSel.value;
|
|
|
|
|
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
|
|
|
|
|
|
|
|
|
|
scanBtn.disabled = true;
|
|
|
|
|
setScanStatus("Loading OCR engine…");
|
|
|
|
|
setScanStatus("Loading crop editor…");
|
|
|
|
|
|
|
|
|
|
let cropResult;
|
|
|
|
|
try {
|
|
|
|
|
cropResult = await openCropper(file);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
setScanStatus(`Crop editor failed: ${e.message || e}`, { error: true });
|
|
|
|
|
scanBtn.disabled = false;
|
|
|
|
|
scanInput.value = "";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!cropResult) {
|
|
|
|
|
// User cancelled
|
|
|
|
|
setScanStatus("");
|
|
|
|
|
scanBtn.disabled = false;
|
|
|
|
|
scanInput.value = "";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const { blob: cropped, invert } = cropResult;
|
|
|
|
|
|
|
|
|
|
setScanStatus("Preparing image…");
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped, { invert });
|
|
|
|
|
if (scaled) {
|
|
|
|
|
setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`);
|
|
|
|
|
} else {
|
|
|
|
|
setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Lazy import — first scan triggers the ~5 MB JS + language pack
|
|
|
|
|
// download; cached by the browser after that.
|
|
|
|
|
const { createWorker } = await import("https://esm.sh/tesseract.js@5");
|
|
|
|
|
@@ -350,15 +862,18 @@
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const { data: { text } } = await worker.recognize(file);
|
|
|
|
|
const { data } = await worker.recognize(blob);
|
|
|
|
|
await worker.terminate();
|
|
|
|
|
|
|
|
|
|
const text = filterByConfidence(data);
|
|
|
|
|
const droppedWords = (data.words || []).filter(w => (w.confidence ?? 100) < OCR_CONFIDENCE_MIN).length;
|
|
|
|
|
editor.dispatch({
|
|
|
|
|
changes: { from: 0, to: editor.state.doc.length, insert: text.trim() },
|
|
|
|
|
changes: { from: 0, to: editor.state.doc.length, insert: text },
|
|
|
|
|
selection: { anchor: 0 },
|
|
|
|
|
});
|
|
|
|
|
const dt = ((performance.now() - t0) / 1000).toFixed(1);
|
|
|
|
|
setScanStatus(`OCR done in ${dt}s (${lang})`);
|
|
|
|
|
const dropped = droppedWords ? `, dropped ${droppedWords} low-confidence` : "";
|
|
|
|
|
setScanStatus(`OCR done in ${dt}s (${lang}${dropped})`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
setScanStatus(`OCR failed: ${e.message || e}`, { error: true });
|
|
|
|
|
@@ -368,7 +883,93 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scanBtn.addEventListener("click", () => scanInput.click());
|
|
|
|
|
// Live camera capture via getUserMedia — bypasses the OS camera's
|
|
|
|
|
// "retake / use" confirm step that <input type=file capture> shows.
|
|
|
|
|
// Tap the shutter → frame is captured and crop modal opens immediately.
|
|
|
|
|
// Returns the captured Blob, "file" if the user wants the file picker,
|
|
|
|
|
// or null on cancel.
|
|
|
|
|
async function captureFromCamera() {
|
|
|
|
|
let stream;
|
|
|
|
|
try {
|
|
|
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
video: { facingMode: { ideal: "environment" } },
|
|
|
|
|
audio: false,
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Camera unavailable:", e);
|
|
|
|
|
return "file"; // fall through to file picker
|
|
|
|
|
}
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const backdrop = document.createElement("div");
|
|
|
|
|
backdrop.className = "modal-backdrop";
|
|
|
|
|
const modal = document.createElement("div");
|
|
|
|
|
modal.className = "modal";
|
|
|
|
|
modal.innerHTML = `
|
|
|
|
|
<div class="modal-header">Aim at the text and tap the shutter</div>
|
|
|
|
|
<div class="modal-body cam-body"><video autoplay playsinline muted></video></div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<div class="group">
|
|
|
|
|
<button class="modal-btn icon" data-act="file" title="Pick from file instead"><span class="material-symbols-outlined">photo_library</span></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="group">
|
|
|
|
|
<button class="modal-btn" data-act="cancel">Cancel</button>
|
|
|
|
|
<button class="modal-btn primary icon" data-act="shoot" title="Capture"><span class="material-symbols-outlined">photo_camera</span></button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
backdrop.appendChild(modal);
|
|
|
|
|
document.body.appendChild(backdrop);
|
|
|
|
|
|
|
|
|
|
const video = modal.querySelector("video");
|
|
|
|
|
video.srcObject = stream;
|
|
|
|
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
stream.getTracks().forEach(t => t.stop());
|
|
|
|
|
video.srcObject = null;
|
|
|
|
|
backdrop.remove();
|
|
|
|
|
document.removeEventListener("keydown", onKey);
|
|
|
|
|
};
|
|
|
|
|
const finish = (r) => { cleanup(); resolve(r); };
|
|
|
|
|
|
|
|
|
|
const onKey = (e) => {
|
|
|
|
|
if (e.key === "Escape") finish(null);
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
|
|
|
|
|
|
modal.addEventListener("click", (e) => {
|
|
|
|
|
const act = e.target.closest("[data-act]")?.dataset.act;
|
|
|
|
|
if (act === "cancel") finish(null);
|
|
|
|
|
else if (act === "file") finish("file");
|
|
|
|
|
else if (act === "shoot") {
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = video.videoWidth;
|
|
|
|
|
canvas.height = video.videoHeight;
|
|
|
|
|
canvas.getContext("2d").drawImage(video, 0, 0);
|
|
|
|
|
canvas.toBlob(blob => finish(blob), "image/jpeg", 0.92);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
backdrop.addEventListener("click", (e) => {
|
|
|
|
|
if (e.target === backdrop) finish(null);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startScan() {
|
|
|
|
|
// On a touch device with getUserMedia, go straight to the live
|
|
|
|
|
// viewfinder. On desktop / no-camera, use the file picker.
|
|
|
|
|
if (isCoarsePointer && navigator.mediaDevices?.getUserMedia) {
|
|
|
|
|
const result = await captureFromCamera();
|
|
|
|
|
if (result === "file") {
|
|
|
|
|
scanInput.click();
|
|
|
|
|
} else if (result) {
|
|
|
|
|
runOCR(result);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
scanInput.click();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scanBtn.addEventListener("click", startScan);
|
|
|
|
|
scanInput.addEventListener("change", (e) => {
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
if (file) runOCR(file);
|
|
|
|
|
@@ -418,7 +1019,7 @@
|
|
|
|
|
o2.value = lang; o2.textContent = lang;
|
|
|
|
|
targetSel.appendChild(o2);
|
|
|
|
|
}
|
|
|
|
|
sourceSel.value = "auto";
|
|
|
|
|
sourceSel.value = "Dutch";
|
|
|
|
|
targetSel.value = "English";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -467,7 +1068,7 @@
|
|
|
|
|
const text = inputText().trim();
|
|
|
|
|
if (!text) return;
|
|
|
|
|
goBtn.disabled = true;
|
|
|
|
|
goBtn.textContent = "Translating…";
|
|
|
|
|
goBtn.classList.add("busy");
|
|
|
|
|
output.textContent = "";
|
|
|
|
|
output.classList.add("streaming");
|
|
|
|
|
|
|
|
|
|
@@ -539,7 +1140,7 @@
|
|
|
|
|
} finally {
|
|
|
|
|
output.classList.remove("streaming");
|
|
|
|
|
goBtn.disabled = false;
|
|
|
|
|
goBtn.textContent = "Translate";
|
|
|
|
|
goBtn.classList.remove("busy");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -547,6 +1148,10 @@
|
|
|
|
|
if (!expiresAt) return null;
|
|
|
|
|
const ms = new Date(expiresAt).getTime() - Date.now();
|
|
|
|
|
if (!isFinite(ms) || ms <= 0) return null;
|
|
|
|
|
// Ollama's keep_alive=-1 returns expires_at far in the future (year 4001).
|
|
|
|
|
// Any realistic finite keep_alive is hours at most, so treat >7 days
|
|
|
|
|
// as "never unloads" and drop the misleading suffix.
|
|
|
|
|
if (ms > 7 * 24 * 60 * 60 * 1000) return null;
|
|
|
|
|
const totalSec = Math.floor(ms / 1000);
|
|
|
|
|
const mins = Math.floor(totalSec / 60);
|
|
|
|
|
const secs = totalSec % 60;
|
|
|
|
|
@@ -651,6 +1256,28 @@
|
|
|
|
|
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Side drawer (Settings) wiring — opens from the top-left hamburger.
|
|
|
|
|
const drawerEl = document.getElementById("drawer");
|
|
|
|
|
const drawerBackdrop = document.getElementById("drawer-backdrop");
|
|
|
|
|
const drawerToggle = document.getElementById("drawer-toggle");
|
|
|
|
|
const drawerCloseBtn = document.getElementById("drawer-close");
|
|
|
|
|
function openDrawer() {
|
|
|
|
|
drawerEl.classList.add("open");
|
|
|
|
|
drawerBackdrop.classList.add("open");
|
|
|
|
|
drawerEl.setAttribute("aria-hidden", "false");
|
|
|
|
|
}
|
|
|
|
|
function closeDrawer() {
|
|
|
|
|
drawerEl.classList.remove("open");
|
|
|
|
|
drawerBackdrop.classList.remove("open");
|
|
|
|
|
drawerEl.setAttribute("aria-hidden", "true");
|
|
|
|
|
}
|
|
|
|
|
drawerToggle.addEventListener("click", openDrawer);
|
|
|
|
|
drawerCloseBtn.addEventListener("click", closeDrawer);
|
|
|
|
|
drawerBackdrop.addEventListener("click", closeDrawer);
|
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
|
|
|
if (e.key === "Escape" && drawerEl.classList.contains("open")) closeDrawer();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fillLangs();
|
|
|
|
|
loadModels().then(() => refreshLoaded(true));
|
|
|
|
|
</script>
|
|
|
|
|
|