feat: update frontend to integrate translation

This commit is contained in:
Kharec committed 2026-10-01 10:15:47 +02:00
1 parent 49df7f36a1
commit 9987d7e1ab
7 files changed
+231

No files matched your search

+12
View File
@@ -38,6 +38,7 @@
<button data-action="find" data-icon="search" data-tip="Find and replace · Ctrl+F"></button>
<button data-action="spelling" data-icon="spell" data-tip="Spelling · F7"></button>
<button data-action="revisions" data-icon="revisions" data-tip="Revisions and locked pages"></button>
<button data-action="translate" data-icon="translate" data-tip="Translate French to English · Ctrl+Shift+L"></button>
</div>
<div class="group">
<button data-action="title" data-icon="title" data-tip="Title page · Ctrl+Shift+T"></button>
@@ -163,6 +164,17 @@
</form>
</dialog>
<dialog id="progress-dialog" class="message">
<form method="dialog">
<h2 id="progress-title"></h2>
<p id="progress-text"></p>
<div class="progress" id="progress"><div class="progress-bar" id="progress-bar"></div></div>
<menu>
<button value="cancel" id="progress-cancel">Cancel</button>
</menu>
</form>
</dialog>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+24
View File
@@ -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<Misspelling[] | null>
SpellSuggest(word: string, lang: string): Promise<string[] | null>
AddToDictionary(word: string): Promise<void>
TranslationStatus(): Promise<TranslationStatus>
/** False if cancelled. Progress: "translation-progress" (bytes done, total). */
DownloadTranslationModel(): Promise<boolean>
/** Null if cancelled. Progress: "translation-progress" (sentences done, total; 0, 0 while loading). */
Translate(doc: ScriptDocument, elements: number[] | null, titlePage: boolean): Promise<TranslationResult | null>
CancelTranslation(): Promise<void>
}
interface WailsRuntime {
+1
View File
@@ -22,6 +22,7 @@ const paths: Record<string, string> = {
note: '<path d="M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z"/><path d="M15 3v6h6"/><path d="M7 13h6"/><path d="M7 17h4"/>',
revisions: '<path d="M12 20h9"/><path d="M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"/><path d="m15 5 3 3"/>',
spell: '<path d="m6 16 6-12 6 12"/><path d="M8 12h8"/><path d="m16 20 2 2 4-4"/>',
translate: '<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
close: '<path d="M17 7 7 17"/><path d="m7 7 10 10"/>',
+107
View File
@@ -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 $ = <T extends HTMLElement>(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<T>(opts: ProgressOptions, task: () => Promise<T>): Promise<T> {
const dialog = $<HTMLDialogElement>('#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<string, () => 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<string, string> = {
f3: 'findNext',
'shift+f3': 'findPrevious',
f7: 'toggleSpelling',
'ctrl+shift+l': 'translate',
'ctrl+=': 'zoomIn',
'ctrl++': 'zoomIn',
'ctrl+-': 'zoomOut',
+9
View File
@@ -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)
+34
View File
@@ -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;
+44
View File
@@ -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)
}