feat: update frontend to integrate translation
This commit is contained in:
1 parent
49df7f36a1
commit
9987d7e1ab
7 files changed
+231
No files matched your search
@@ -38,6 +38,7 @@
|
|||||||
<button data-action="find" data-icon="search" data-tip="Find and replace · Ctrl+F"></button>
|
<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="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="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>
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<button data-action="title" data-icon="title" data-tip="Title page · Ctrl+Shift+T"></button>
|
<button data-action="title" data-icon="title" data-tip="Title page · Ctrl+Shift+T"></button>
|
||||||
@@ -163,6 +164,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -120,6 +120,24 @@ export interface WindowFrame {
|
|||||||
rounded: boolean
|
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 {
|
export interface ExportOptions {
|
||||||
sceneNumbers: boolean
|
sceneNumbers: boolean
|
||||||
titlePage: boolean
|
titlePage: boolean
|
||||||
@@ -146,6 +164,12 @@ interface GoApp {
|
|||||||
SpellCheck(doc: ScriptDocument, lang: string): Promise<Misspelling[] | null>
|
SpellCheck(doc: ScriptDocument, lang: string): Promise<Misspelling[] | null>
|
||||||
SpellSuggest(word: string, lang: string): Promise<string[] | null>
|
SpellSuggest(word: string, lang: string): Promise<string[] | null>
|
||||||
AddToDictionary(word: string): Promise<void>
|
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 {
|
interface WailsRuntime {
|
||||||
|
|||||||
@@ -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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
chevron: '<path d="m6 9 6 6 6-6"/>',
|
chevron: '<path d="m6 9 6 6 6-6"/>',
|
||||||
close: '<path d="M17 7 7 17"/><path d="m7 7 10 10"/>',
|
close: '<path d="M17 7 7 17"/><path d="m7 7 10 10"/>',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { buildSpellDecorations, misspellingAt, spellKey, spellPlugin, withoutWor
|
|||||||
import { closeMenu, menuOpen, openMenu, type MenuItem } from './menu'
|
import { closeMenu, menuOpen, openMenu, type MenuItem } from './menu'
|
||||||
import { activeMatch, replaceAll, replaceMatch, searchMatches, searchPlugin, selectMatch, setSearch } from './search'
|
import { activeMatch, replaceAll, replaceMatch, searchMatches, searchPlugin, selectMatch, setSearch } from './search'
|
||||||
import { clearRevisionMarks, lockPages, lockPlugin, revisionPlugin, unlockPages, type CurrentRevision } from './revisions'
|
import { clearRevisionMarks, lockPages, lockPlugin, revisionPlugin, unlockPages, type CurrentRevision } from './revisions'
|
||||||
|
import { applyTranslation, selectedElements } from './translate'
|
||||||
import { icon } from './icons'
|
import { icon } from './icons'
|
||||||
|
|
||||||
const $ = <T extends HTMLElement>(sel: string) => document.querySelector(sel) as T
|
const $ = <T extends HTMLElement>(sel: string) => document.querySelector(sel) as T
|
||||||
@@ -736,6 +737,110 @@ replaceInput.addEventListener('keydown', (e) => {
|
|||||||
e.preventDefault()
|
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
|
// ----------------------------------------------------------------- files
|
||||||
|
|
||||||
interface MessageOptions {
|
interface MessageOptions {
|
||||||
@@ -877,6 +982,7 @@ const actions: Record<string, () => unknown> = {
|
|||||||
underline: () => toggleMark(schema.marks.underline)(view.state, view.dispatch),
|
underline: () => toggleMark(schema.marks.underline)(view.state, view.dispatch),
|
||||||
note: () => toggleMark(schema.marks.note)(view.state, view.dispatch),
|
note: () => toggleMark(schema.marks.note)(view.state, view.dispatch),
|
||||||
revisions: revisionsMenu,
|
revisions: revisionsMenu,
|
||||||
|
translate: translateScript,
|
||||||
dual: () => toggleDual(view.state, view.dispatch),
|
dual: () => toggleDual(view.state, view.dispatch),
|
||||||
navigator() {
|
navigator() {
|
||||||
document.body.classList.toggle('no-navigator')
|
document.body.classList.toggle('no-navigator')
|
||||||
@@ -931,6 +1037,7 @@ const shortcuts: Record<string, string> = {
|
|||||||
f3: 'findNext',
|
f3: 'findNext',
|
||||||
'shift+f3': 'findPrevious',
|
'shift+f3': 'findPrevious',
|
||||||
f7: 'toggleSpelling',
|
f7: 'toggleSpelling',
|
||||||
|
'ctrl+shift+l': 'translate',
|
||||||
'ctrl+=': 'zoomIn',
|
'ctrl+=': 'zoomIn',
|
||||||
'ctrl++': 'zoomIn',
|
'ctrl++': 'zoomIn',
|
||||||
'ctrl+-': 'zoomOut',
|
'ctrl+-': 'zoomOut',
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ export function unlockPages(state: EditorState): Transaction {
|
|||||||
return setLocks(state.tr, new Map()).setMeta(INTERNAL, true).setMeta('addToHistory', false)
|
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. */
|
/** Removes every revision mark; one undo step. */
|
||||||
export function clearRevisionMarks(state: EditorState): Transaction {
|
export function clearRevisionMarks(state: EditorState): Transaction {
|
||||||
return state.tr.removeMark(0, state.doc.content.size, schema.marks.revision).setMeta(INTERNAL, true)
|
return state.tr.removeMark(0, state.doc.content.size, schema.marks.revision).setMeta(INTERNAL, true)
|
||||||
|
|||||||
@@ -1539,6 +1539,40 @@ dialog.message p {
|
|||||||
user-select: text;
|
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 {
|
dialog menu button:focus-visible {
|
||||||
outline: 2px solid var(--accent-2);
|
outline: 2px solid var(--accent-2);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user