feat: vendor all browser deps into the image (no runtime CDN)
esm.sh's degraded storage backend served an unpinned transitive @codemirror/view@^6.27.0 range URL as text/plain, which browsers block for a disallowed MIME type. A failed top-level module import aborts the whole <script type=module>, so the language/model dropdowns came up empty despite a healthy backend — and top-level version pinning can't fix a transitive range. Add a frontend/ esbuild stage (Node stage in the Dockerfile) that vendors everything into static/vendor and serve only from there: - CodeMirror (view/state/commands) bundled into one self-contained ESM file - cropperjs (+ CSS) copied from npm dist - Tesseract.js engine + wasm core + Dutch (nld) language pack; OCR assets stay lazy (fetched only when a scan runs) and fully local - Drop vim mode (@replit/codemirror-vim); keep document-level Shift-H/L Google Fonts remains the only external <link> (degrades gracefully, not in the failing path). Bump to 0.5.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -474,13 +474,6 @@
|
||||
}
|
||||
.cm-scroller { padding: 0.6rem 0.75rem; }
|
||||
.cm-content { caret-color: var(--accent); }
|
||||
/* vim's command-line / status hint inherits readable colors */
|
||||
.cm-vim-panel {
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
@@ -550,10 +543,11 @@
|
||||
</button>
|
||||
|
||||
<script type="module">
|
||||
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
|
||||
import { EditorState } from "https://esm.sh/@codemirror/state@6";
|
||||
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6";
|
||||
import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
|
||||
import {
|
||||
EditorView, keymap, placeholder,
|
||||
EditorState,
|
||||
history, defaultKeymap, historyKeymap,
|
||||
} from "/static/vendor/codemirror.js";
|
||||
|
||||
const LANGUAGES = [
|
||||
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
||||
@@ -576,7 +570,7 @@
|
||||
const scanInput = document.getElementById("scan-input");
|
||||
const scanStatus = document.getElementById("scan-status");
|
||||
|
||||
// --- OCR via Tesseract.js (lazy-loaded on first use from esm.sh) --------
|
||||
// --- OCR via Tesseract.js (vendored; lazy-loaded on first use) ----------
|
||||
// Tesseract language codes are ISO 639-2 — map from our "From" dropdown.
|
||||
const TESS_LANGS = {
|
||||
"English": "eng",
|
||||
@@ -618,7 +612,7 @@
|
||||
scanStatus.classList.toggle("error", error);
|
||||
}
|
||||
|
||||
// Crop + rotate modal. Lazy-loads cropperjs + its CSS from esm.sh on
|
||||
// Crop + rotate modal. Lazy-loads the vendored cropperjs + its CSS on
|
||||
// first use. Resolves with a Blob (cropped, rotation baked in) or null
|
||||
// if the user cancelled.
|
||||
let cropperCssLoaded = false;
|
||||
@@ -626,11 +620,11 @@
|
||||
if (!cropperCssLoaded) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://esm.sh/cropperjs@1/dist/cropper.min.css";
|
||||
link.href = "/static/vendor/cropper.min.css";
|
||||
document.head.appendChild(link);
|
||||
cropperCssLoaded = true;
|
||||
}
|
||||
const { default: Cropper } = await import("https://esm.sh/cropperjs@1");
|
||||
const { default: Cropper } = await import("/static/vendor/cropper.esm.js");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement("div");
|
||||
@@ -850,10 +844,15 @@
|
||||
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");
|
||||
// Lazy import — the vendored engine, wasm core, and language pack are
|
||||
// only fetched from the container on first scan, then browser-cached.
|
||||
// Everything is served locally (workerPath/corePath/langPath) so OCR
|
||||
// never depends on a CDN. Only the Dutch pack (nld) is bundled.
|
||||
const { createWorker } = await import("/static/vendor/tesseract/tesseract.esm.min.js");
|
||||
const worker = await createWorker(lang, 1, {
|
||||
workerPath: "/static/vendor/tesseract/worker.min.js",
|
||||
corePath: "/static/vendor/tesseract",
|
||||
langPath: "/static/vendor/tesseract/lang",
|
||||
logger: (m) => {
|
||||
if (m.status === "recognizing text") {
|
||||
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
|
||||
@@ -975,18 +974,13 @@
|
||||
if (file) runOCR(file);
|
||||
});
|
||||
|
||||
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
|
||||
// --- CodeMirror editor --------------------------------------------------
|
||||
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
let typingTimer;
|
||||
const editor = new EditorView({
|
||||
parent: document.getElementById("input"),
|
||||
state: EditorState.create({
|
||||
extensions: [
|
||||
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
|
||||
// takes precedence. Skipped on touch devices — modal editing without
|
||||
// a hardware Esc key on a virtual keyboard is unusable, and IME
|
||||
// composition fights the vim keymap.
|
||||
...(isCoarsePointer ? [] : [vim()]),
|
||||
history(),
|
||||
keymap.of([
|
||||
{ key: "Mod-Enter", run: () => { translate(); return true; } },
|
||||
@@ -1214,10 +1208,8 @@
|
||||
goBtn.addEventListener("click", translate);
|
||||
|
||||
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
|
||||
// Tab). Two paths kept in sync:
|
||||
// 1. Document keydown listener — fires when focus is on a select / button
|
||||
// 2. Vim normal-mode mapping (below) — fires when focus is in the editor
|
||||
// Together they make Shift-H/L work everywhere, including escaping the editor.
|
||||
// Tab), for focus on a select / button. Inside the editor they type
|
||||
// normally.
|
||||
const focusables = [
|
||||
{ el: sourceSel, focus: () => sourceSel.focus() },
|
||||
{ el: targetSel, focus: () => targetSel.focus() },
|
||||
@@ -1239,23 +1231,12 @@
|
||||
if (!e.shiftKey) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== "h" && k !== "l") return;
|
||||
// Inside the editor, vim's mapCommand handles H/L (see below). Letting
|
||||
// the event bubble here would double-jump.
|
||||
// Inside the editor, let H/L type normally.
|
||||
if (editor.dom.contains(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
moveFocus(k === "l" ? 1 : -1);
|
||||
});
|
||||
|
||||
// Override vim's H/L (normally "viewport top/bottom") so they exit the
|
||||
// editor to the previous/next control, matching the Shift-H/L contract
|
||||
// the document handler uses elsewhere.
|
||||
if (!isCoarsePointer) {
|
||||
Vim.defineAction("finrodFocusPrev", () => moveFocus(-1));
|
||||
Vim.defineAction("finrodFocusNext", () => moveFocus(1));
|
||||
Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" });
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user