From 9987d7e1ab5fb97c447ea0de1608da4b94a2f1aa Mon Sep 17 00:00:00 2001 From: Kharec Date: Thu, 1 Oct 2026 10:15:47 +0200 Subject: [PATCH] feat: update frontend to integrate translation --- frontend/index.html | 12 +++++ frontend/src/backend.ts | 24 +++++++++ frontend/src/icons.ts | 1 + frontend/src/main.ts | 107 ++++++++++++++++++++++++++++++++++++++ frontend/src/revisions.ts | 9 ++++ frontend/src/style.css | 34 ++++++++++++ frontend/src/translate.ts | 44 ++++++++++++++++ 7 files changed, 231 insertions(+) create mode 100644 frontend/src/translate.ts diff --git a/frontend/index.html b/frontend/index.html index 2001f71..e4619fa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -38,6 +38,7 @@ +
@@ -163,6 +164,17 @@ + +
+

+

+
+ + + +
+
+ diff --git a/frontend/src/backend.ts b/frontend/src/backend.ts index ca008d4..276cbea 100644 --- a/frontend/src/backend.ts +++ b/frontend/src/backend.ts @@ -120,6 +120,24 @@ export interface WindowFrame { rounded: boolean } +/** Whether the translation model is installed, and its download size in bytes. */ +export interface TranslationStatus { + installed: boolean + size: number +} + +/** A translated element, to put in place of element `index` (flat order). */ +export interface TranslatedElement { + index: number + element: Element +} + +export interface TranslationResult { + changes: TranslatedElement[] | null + /** Set when the title page changed. */ + titlePage?: TitlePage +} + export interface ExportOptions { sceneNumbers: boolean titlePage: boolean @@ -146,6 +164,12 @@ interface GoApp { SpellCheck(doc: ScriptDocument, lang: string): Promise SpellSuggest(word: string, lang: string): Promise AddToDictionary(word: string): Promise + TranslationStatus(): Promise + /** False if cancelled. Progress: "translation-progress" (bytes done, total). */ + DownloadTranslationModel(): Promise + /** Null if cancelled. Progress: "translation-progress" (sentences done, total; 0, 0 while loading). */ + Translate(doc: ScriptDocument, elements: number[] | null, titlePage: boolean): Promise + CancelTranslation(): Promise } interface WailsRuntime { diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index c4b8387..8147789 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -22,6 +22,7 @@ const paths: Record = { note: '', revisions: '', spell: '', + translate: '', check: '', chevron: '', close: '', diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2bc1cc2..b024827 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -36,6 +36,7 @@ import { buildSpellDecorations, misspellingAt, spellKey, spellPlugin, withoutWor import { closeMenu, menuOpen, openMenu, type MenuItem } from './menu' import { activeMatch, replaceAll, replaceMatch, searchMatches, searchPlugin, selectMatch, setSearch } from './search' import { clearRevisionMarks, lockPages, lockPlugin, revisionPlugin, unlockPages, type CurrentRevision } from './revisions' +import { applyTranslation, selectedElements } from './translate' import { icon } from './icons' const $ = (sel: string) => document.querySelector(sel) as T @@ -736,6 +737,110 @@ replaceInput.addEventListener('keydown', (e) => { e.preventDefault() }) +// ----------------------------------------------------------- translation + +interface ProgressOptions { + title: string + /** The line under the bar, from Go's progress (a total of 0: not started yet). */ + describe: (done: number, total: number) => string +} + +/** + * Runs a Go task that reports "translation-progress" behind a progress + * dialog; closing the dialog (Cancel, Esc) cancels the task. + */ +async function withProgress(opts: ProgressOptions, task: () => Promise): Promise { + const dialog = $('#progress-dialog') + const track = $('#progress') + const bar = $('#progress-bar') + const show = (done: number, total: number) => { + track.dataset.state = total ? '' : 'busy' + bar.style.transform = total ? `scaleX(${Math.min(1, done / total)})` : '' + $('#progress-text').textContent = opts.describe(done, total) + } + $('#progress-title').textContent = opts.title + show(0, 0) + const off = runtime.EventsOn('translation-progress', (done, total) => show(Number(done), Number(total))) + let running = true + dialog.onclose = () => { + if (running) api.CancelTranslation().catch(console.error) + } + typeMenu.hidden = true + dialog.showModal() + try { + return await task() + } finally { + running = false + off() + if (dialog.open) dialog.close() + view.focus() + } +} + +const megabytes = (bytes: number) => Math.round(bytes / 1e6).toLocaleString('en-US') + +/** + * Translates the French text of the selection, or of the whole script and + * its title page, into English, offline. Text already in English is left + * as it is, so a script can be translated a part at a time. + */ +async function translateScript() { + const status = await api.TranslationStatus() + if (!status.installed) { + const download = await messageBox({ + title: 'Download the translation model?', + text: `Plotzilla translates French into English on this computer, without an internet connection. It needs the OPUS-MT French–English model (${megabytes(status.size)} MB), downloaded once from Hugging Face.`, + ok: 'Download', + cancel: 'Cancel', + }) + if (!download) return + const installed = await withProgress( + { + title: 'Downloading the translation model', + describe: (done, total) => (total ? `${megabytes(done)} of ${megabytes(total)} MB` : 'Connecting…'), + }, + () => api.DownloadTranslationModel(), + ) + if (!installed) return + } + + const doc = view.state.doc + const elements = selectedElements(view.state) + const whole = elements === null + const result = await withProgress( + { + title: whole ? 'Translating the script' : 'Translating the selection', + describe: (done, total) => + total ? `${done.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} sentences` : 'Loading the translation model…', + }, + () => api.Translate(toScript(doc, app.titlePage, app.settings), elements, whole), + ) + if (!result) return // cancelled + if (view.state.doc !== doc) { + await messageBox({ + title: 'Translation not applied', + text: 'The script changed while it was being translated. Translate it again to include the changes.', + ok: 'OK', + }) + return + } + const changes = result.changes ?? [] + if (changes.length) { + view.dispatch(applyTranslation(view.state, changes, app.settings.revisions ?? [], currentRevision())) + } + if (result.titlePage) { + app.titlePage = { ...result.titlePage } + markChanged() + } + if (!changes.length && !result.titlePage) { + await messageBox({ + title: 'Nothing to translate', + text: `No French text was found in the ${whole ? 'script' : 'selection'}. Text already in English is left as it is.`, + ok: 'OK', + }) + } +} + // ----------------------------------------------------------------- files interface MessageOptions { @@ -877,6 +982,7 @@ const actions: Record unknown> = { underline: () => toggleMark(schema.marks.underline)(view.state, view.dispatch), note: () => toggleMark(schema.marks.note)(view.state, view.dispatch), revisions: revisionsMenu, + translate: translateScript, dual: () => toggleDual(view.state, view.dispatch), navigator() { document.body.classList.toggle('no-navigator') @@ -931,6 +1037,7 @@ const shortcuts: Record = { f3: 'findNext', 'shift+f3': 'findPrevious', f7: 'toggleSpelling', + 'ctrl+shift+l': 'translate', 'ctrl+=': 'zoomIn', 'ctrl++': 'zoomIn', 'ctrl+-': 'zoomOut', diff --git a/frontend/src/revisions.ts b/frontend/src/revisions.ts index 569cff9..ef208d5 100644 --- a/frontend/src/revisions.ts +++ b/frontend/src/revisions.ts @@ -131,6 +131,15 @@ export function unlockPages(state: EditorState): Transaction { return setLocks(state.tr, new Map()).setMeta(INTERNAL, true).setMeta('addToHistory', false) } +/** + * Lets a transaction set the locked pages itself (in the elements' `locked` + * attribute) instead of the plugin mapping them. Revision marks are then + * the transaction's job too. + */ +export function ownLocks(tr: Transaction): Transaction { + return tr.setMeta(INTERNAL, true) +} + /** Removes every revision mark; one undo step. */ export function clearRevisionMarks(state: EditorState): Transaction { return state.tr.removeMark(0, state.doc.content.size, schema.marks.revision).setMeta(INTERNAL, true) diff --git a/frontend/src/style.css b/frontend/src/style.css index 8868a21..aa505bc 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1539,6 +1539,40 @@ dialog.message p { user-select: text; } +/* Progress of a download or a translation. The bar is scaled, not resized, + so only transform is animated; without a total it sweeps back and forth. */ +.progress { + height: 6px; + margin: -4px 0 18px; + border-radius: 3px; + background: var(--accent-soft); + overflow: hidden; +} + +.progress-bar { + height: 100%; + border-radius: 3px; + background: var(--accent); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s var(--ease); +} + +.progress[data-state='busy'] .progress-bar { + width: 30%; + transform: none; + animation: sweep 1.2s var(--ease) infinite alternate; +} + +@keyframes sweep { + from { + translate: 0; + } + to { + translate: 233%; + } +} + dialog menu button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; diff --git a/frontend/src/translate.ts b/frontend/src/translate.ts new file mode 100644 index 0000000..b50d2d9 --- /dev/null +++ b/frontend/src/translate.ts @@ -0,0 +1,44 @@ +// Applying a French → English translation computed in Go: which elements +// the selection covers, and the transaction that puts the translated +// elements in place. +import type { EditorState, Transaction } from 'prosemirror-state' +import type { Revision, TranslatedElement } from './backend' +import { flatElements, fromElement, schema } from './schema' +import { ownLocks, type CurrentRevision } from './revisions' + +/** Flat indexes of the elements the selection touches; null for the whole script. */ +export function selectedElements(state: EditorState): number[] | null { + const { from, to, empty } = state.selection + if (empty) return null + const out: number[] = [] + flatElements(state.doc).forEach(({ node, pos }, i) => { + if (pos + node.nodeSize > from && pos < to) out.push(i) + }) + return out +} + +/** + * Replaces each translated element, in one undoable step. Go has already + * placed the element's locked pages in the translated text, so the lock + * plugin is told to leave them; translated text is marked with the current + * revision set when revisions are being marked. + */ +export function applyTranslation( + state: EditorState, + changes: TranslatedElement[], + revisions: Revision[], + current: CurrentRevision | null, +): Transaction { + const flat = flatElements(state.doc) + const tr = state.tr + const mark = current && schema.marks.revision.create(current) + // From the end, so the positions of the elements before stay valid. + for (const change of [...changes].sort((a, b) => b.index - a.index)) { + const at = flat[change.index] + if (!at) continue + const node = fromElement(change.element, revisions) + tr.replaceWith(at.pos, at.pos + at.node.nodeSize, node) + if (mark && node.content.size) tr.addMark(at.pos + 1, at.pos + 1 + node.content.size, mark) + } + return ownLocks(tr) +}