Files
plotzilla/frontend/src/main.ts
T

1158 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'prosemirror-view/style/prosemirror.css'
import './style.css'
import { EditorState, TextSelection } from 'prosemirror-state'
import { DecorationSet, EditorView } from 'prosemirror-view'
import { Fragment, MarkType, Node as PMNode, Slice } from 'prosemirror-model'
import { keymap } from 'prosemirror-keymap'
import { baseKeymap, toggleMark } from 'prosemirror-commands'
import { history } from 'prosemirror-history'
import {
api,
runtime,
type ElementType,
type LayoutInfo,
type ScriptDocument,
type Settings,
type TitlePage,
} from './backend'
import {
ELEMENT_TYPES,
LABELS,
elementType,
flatElements,
fromElement,
fromScript,
isPrinted,
printedText,
schema,
toScript,
} from './schema'
import { NEXT_ON_ENTER, TAB, autoFormatPlugin, replaceText, screenplayKeys, setType, toggleDual } from './commands'
import { autocompletePlugin } from './autocomplete'
import { buildPageDecorations, pagesKey, pagesPlugin, placeholderPlugin } from './decorations'
import { buildSpellDecorations, misspellingAt, spellKey, spellPlugin, withoutWord, type MisspelledWord } from './spellcheck'
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
const AUTOSAVE_DELAY = 1200
const app = {
path: '',
dirty: false,
revision: 0, // bumped on every change, so an autosave knows if it is still current
saving: false,
draftSaved: false, // untitled script copied to the recovery file
titlePage: { title: '', credit: 'Written by', author: '', source: '', draftDate: '', contact: '', notes: '' } as TitlePage,
settings: {} as Settings, // paper, revisions, locked pages
layout: null as LayoutInfo | null,
zoom: Number(load('zoom') ?? 1),
autosave: load('autosave') !== 'off',
spelling: load('spelling') !== 'off',
spellLang: load('spellLang') ?? '',
spellLanguages: [] as string[],
ignoredWords: new Set<string>(), // "Ignore All", for this session
}
function load(key: string): string | null {
try {
return localStorage.getItem(`plotzilla.${key}`)
} catch {
return null
}
}
function store(key: string, value: string) {
try {
localStorage.setItem(`plotzilla.${key}`, value)
} catch {
/* storage unavailable: preferences are not kept */
}
}
document.querySelectorAll<HTMLElement>('[data-icon]').forEach((el) => {
el.insertAdjacentHTML('afterbegin', icon(el.dataset.icon!))
})
// ---------------------------------------------------------------- editor
const plugins = [
autocompletePlugin(),
autoFormatPlugin(),
keymap(screenplayKeys()),
keymap(baseKeymap),
history(),
pagesPlugin(),
placeholderPlugin(),
searchPlugin(),
spellPlugin(),
revisionPlugin(currentRevision),
lockPlugin(() => !!app.settings.locked),
]
/** The revision set new text is marked with, while revisions are on. */
function currentRevision(): CurrentRevision | null {
const sets = app.settings.revisions ?? []
if (!app.settings.revising || !sets.length) return null
return { level: sets.length, color: sets[sets.length - 1].color }
}
const newState = (doc: PMNode) => EditorState.create({ doc, plugins })
const view = new EditorView($('#editor'), {
state: newState(fromScript({ titlePage: app.titlePage, elements: [{ type: 'scene', runs: [] }] })),
dispatchTransaction(tr) {
view.updateState(view.state.apply(tr))
if (tr.docChanged) {
markChanged()
schedulePagination()
scheduleSpellCheck()
}
updateStatus()
},
handlePaste(view, event) {
const data = event.clipboardData
if (!data) return false
if (data.getData('text/html').includes('data-type=')) return false // copied from Plotzilla itself
const text = data.getData('text/plain')
if (!text.includes('\n')) return false
// Multi-line plain text is read as Fountain so pasted scripts keep their formatting.
api
.ParseFountain(text)
.then((elements) => {
if (!elements?.length) return
const revisions = app.settings.revisions ?? []
const slice = new Slice(Fragment.fromArray(elements.map((el) => fromElement(el, revisions))), 1, 1)
view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView())
})
.catch(reportError)
return true
},
})
function script(): ScriptDocument {
return toScript(view.state.doc, app.titlePage, app.settings)
}
function loadScript(doc: ScriptDocument, path: string) {
app.titlePage = { ...app.titlePage, ...doc.titlePage }
app.settings = { ...doc.settings }
renderPaper()
app.path = path
app.layout = null
app.draftSaved = false
clearTimeout(autosaveTimer)
const state = newState(fromScript(doc))
view.updateState(state.apply(state.tr.setSelection(TextSelection.atStart(state.doc))))
setDirty(false)
renderNavigator()
updateStatus()
paginate()
spellCheck()
$('#scroller').scrollTop = 0
view.focus()
}
// ------------------------------------------------------ saving & autosave
let autosaveTimer = 0
function markChanged() {
app.revision++
setDirty(true)
scheduleAutosave()
}
function scheduleAutosave() {
clearTimeout(autosaveTimer)
if (app.autosave && app.dirty) autosaveTimer = window.setTimeout(autosave, AUTOSAVE_DELAY)
}
/**
* Saves silently: into the script's own file when it has one, otherwise into
* a recovery file offered back at the next launch.
*/
async function autosave() {
if (!app.dirty) return
if (app.saving) return scheduleAutosave()
const revision = app.revision
app.saving = true
renderSaveState()
try {
if (app.path) {
await api.Save(script(), app.path)
if (app.revision === revision) setDirty(false)
} else {
await api.SaveRecovery(script())
app.draftSaved = app.revision === revision
}
} catch (err) {
console.error('autosave', err)
} finally {
app.saving = false
renderSaveState()
}
}
async function saveTo(path: string | Promise<string>) {
const revision = app.revision
const wasUntitled = !app.path
const written = await path
if (!written) return
app.path = written
if (app.revision === revision) setDirty(false)
else renderSaveState()
if (wasUntitled) api.ClearRecovery().catch(console.error)
}
function setDirty(dirty: boolean) {
if (app.dirty !== dirty) {
app.dirty = dirty
api.SetDirty(dirty).catch(console.error)
}
if (dirty) app.draftSaved = false
renderSaveState()
}
function renderSaveState() {
const name = app.path ? app.path.split('/').pop()! : 'Untitled'
runtime.WindowSetTitle(`${app.dirty ? '• ' : ''}${name} — Plotzilla`)
$('#doc-name').textContent = name
const el = $('#save-state')
let state = ''
let label = ''
if (app.saving) [state, label] = ['saving', 'Saving…']
else if (app.dirty && app.draftSaved) [state, label] = ['draft', 'Draft backed up']
else if (app.dirty) [state, label] = ['dirty', 'Edited']
else if (app.path) [state, label] = ['saved', app.autosave ? 'Saved · auto' : 'Saved']
el.dataset.state = state
el.textContent = label
}
const autosaveToggle = $<HTMLInputElement>('#autosave-toggle')
autosaveToggle.checked = app.autosave
autosaveToggle.addEventListener('change', () => {
app.autosave = autosaveToggle.checked
store('autosave', app.autosave ? 'on' : 'off')
scheduleAutosave()
renderSaveState()
})
// ------------------------------------------------------------ pagination
let paginationTimer = 0
function schedulePagination() {
clearTimeout(paginationTimer)
paginationTimer = window.setTimeout(paginate, 300)
}
async function paginate() {
const doc = view.state.doc
try {
const info = await api.Paginate(toScript(doc, app.titlePage, app.settings))
if (view.state.doc !== doc) return // stale: a newer run is scheduled
app.layout = info
view.dispatch(view.state.tr.setMeta(pagesKey, buildPageDecorations(doc, info.breaks ?? [])).setMeta('addToHistory', false))
renderNavigator()
updateStatus()
} catch (err) {
console.error(err)
}
}
// -------------------------------------------------------------- spelling
let spellTimer = 0
function scheduleSpellCheck() {
clearTimeout(spellTimer)
spellTimer = window.setTimeout(spellCheck, 500)
}
function showMisspellings(set: DecorationSet) {
view.dispatch(view.state.tr.setMeta(spellKey, set).setMeta('addToHistory', false))
}
async function spellCheck() {
clearTimeout(spellTimer)
const doc = view.state.doc
if (!app.spelling || !app.spellLang) {
showMisspellings(DecorationSet.empty)
return
}
try {
const found = await api.SpellCheck(toScript(doc, app.titlePage, app.settings), app.spellLang)
if (view.state.doc !== doc) return // stale: a newer check is scheduled
showMisspellings(buildSpellDecorations(doc, found ?? [], app.ignoredWords))
} catch (err) {
console.error('spelling', err)
}
}
async function setupSpelling() {
const info = await api.SpellLanguages()
app.spellLanguages = info.languages ?? []
if (!app.spellLanguages.includes(app.spellLang)) app.spellLang = info.default
updateStatus()
spellCheck()
}
const languageNames = new Intl.DisplayNames(['en'], { type: 'language' })
/** "fr_FR" → "French (France)"; dictionaries are named after their locale. */
function languageName(code: string): string {
try {
return languageNames.of(code.replace(/_/g, '-')) ?? code
} catch {
return code // not a valid language tag, e.g. "de_DE_frami"
}
}
function setSpelling(on: boolean, lang = app.spellLang) {
app.spelling = on
app.spellLang = lang
store('spelling', on ? 'on' : 'off')
store('spellLang', lang)
updateStatus()
spellCheck()
}
function spellingMenu() {
const button = $('[data-action="spelling"]')
if (menuOpen()) return closeMenu()
const items: MenuItem[] = [
{ label: 'Check Spelling', checked: app.spelling, hint: 'F7', action: () => setSpelling(!app.spelling) },
{ separator: true },
]
if (!app.spellLanguages.length) {
items.push({ label: 'No dictionary installed', disabled: true })
items.push({ label: 'Install hunspell-en_us or another Hunspell dictionary', disabled: true })
}
for (const code of app.spellLanguages) {
items.push({ label: languageName(code), checked: code === app.spellLang, hint: code, action: () => setSpelling(true, code) })
}
const rect = button.getBoundingClientRect()
openMenu(items, rect.left, rect.bottom + 8, { from: button })
}
/** Right-click on an underlined word: corrections, add to dictionary, ignore. */
async function correctionMenu(word: MisspelledWord, x: number, y: number) {
const lang = app.spellLang
const suggestions = (await api.SpellSuggest(word.word, lang).catch(reportError)) ?? []
const doc = view.state.doc
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, word.from, word.to)))
// Applies only if the word is still there, unchanged.
const unchanged = () => view.state.doc.textBetween(word.from, word.to) === doc.textBetween(word.from, word.to)
const forget = () => {
showMisspellings(withoutWord(view.state, word.word))
view.focus()
}
const items: MenuItem[] = suggestions.map((s) => ({
label: s,
strong: true,
action() {
if (unchanged()) view.dispatch(replaceText(view.state.tr, word.from, word.to, s))
view.focus()
},
}))
if (!items.length) items.push({ label: 'No suggestions', disabled: true })
items.push(
{ separator: true },
{
label: 'Add to Dictionary',
action: () => {
api.AddToDictionary(word.word).then(forget).catch(reportError)
},
},
{
label: 'Ignore All',
action() {
app.ignoredWords.add(word.word.toLowerCase())
forget()
},
},
)
openMenu(items, x, y)
}
view.dom.addEventListener('contextmenu', (e) => {
const at = view.posAtCoords({ left: e.clientX, top: e.clientY })
const word = at && misspellingAt(view.state, at.pos)
if (!word) return
e.preventDefault()
correctionMenu(word, e.clientX, e.clientY)
})
// ------------------------------------------------------------- navigator
/**
* The outline: scenes, with the sections that group them and the synopses
* that sum them up.
*/
function renderNavigator() {
const pages = new Map((app.layout?.scenes ?? []).map((s) => [s.element, s.label || String(s.page)]))
const items: HTMLLIElement[] = []
let scenes = 0
flatElements(view.state.doc).forEach(({ node, pos }, index) => {
const type = elementType(node)
const text = printedText(node).trim()
if (!text || (type !== 'scene' && type !== 'section' && type !== 'synopsis')) return
const li = document.createElement('li')
li.dataset.pos = String(pos)
li.addEventListener('click', () => goTo(pos))
items.push(li)
if (type !== 'scene') {
li.className = `nav-${type}`
if (type === 'section') li.dataset.level = String(Math.min(node.attrs.level, 3))
li.textContent = text
return
}
scenes++
const num = document.createElement('span')
num.className = 'num'
num.textContent = node.attrs.sceneNumber || String(scenes)
const label = document.createElement('span')
label.className = 'text'
label.textContent = text.toUpperCase()
const page = document.createElement('span')
page.className = 'page'
page.textContent = pages.has(index) ? `p. ${pages.get(index)}` : ''
li.append(num, label, page)
})
$('#scene-list').replaceChildren(...items)
$('#scene-count').textContent = scenes ? String(scenes) : ''
$('#scene-empty').hidden = items.length > 0
highlightScene()
}
function goTo(pos: number) {
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos + 1)))
view.focus()
;(view.nodeDOM(pos) as HTMLElement | null)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}
function highlightScene() {
const cursor = view.state.selection.from
let active: HTMLElement | null = null
for (const li of $('#scene-list').children as HTMLCollectionOf<HTMLElement>) {
li.classList.remove('active')
if (Number(li.dataset.pos) < cursor) active = li
}
active?.classList.add('active')
}
// ------------------------------------------------------------- type menu
const typeMenu = $<HTMLUListElement>('#type-menu')
const typeButton = $<HTMLButtonElement>('#type-button')
let menuIndex = 0
ELEMENT_TYPES.forEach((type, i) => {
const li = document.createElement('li')
li.style.setProperty('--dot', `var(--t-${type})`)
// Sections and synopses have no Ctrl shortcut: they are typed as in Fountain.
const key = i < 9 ? `Ctrl+${i + 1}` : type === 'section' ? '#' : '='
li.innerHTML = `<span class="type-dot"></span>${LABELS[type]}<kbd>${key}</kbd>`
li.addEventListener('mousedown', (e) => {
e.preventDefault()
chooseType(type)
})
typeMenu.appendChild(li)
})
function openTypeMenu() {
const rect = typeButton.getBoundingClientRect()
typeMenu.style.left = `${rect.left}px`
typeMenu.style.top = `${rect.bottom + 8}px`
menuIndex = ELEMENT_TYPES.indexOf(elementType(view.state.selection.$from.parent))
renderTypeMenu()
typeMenu.hidden = false
}
function renderTypeMenu() {
;[...typeMenu.children].forEach((li, i) => li.classList.toggle('active', i === menuIndex))
}
function chooseType(type: ElementType) {
typeMenu.hidden = true
setType(type)(view.state, view.dispatch)
view.focus()
}
typeButton.addEventListener('mousedown', (e) => {
e.preventDefault()
if (typeMenu.hidden) openTypeMenu()
else typeMenu.hidden = true
})
document.addEventListener('mousedown', (e) => {
if (!typeMenu.hidden && !typeMenu.contains(e.target as Node) && e.target !== typeButton) typeMenu.hidden = true
})
// ---------------------------------------------------------------- status
/** The printed number of the page under the cursor ("12", "12A"). */
function currentPage(): string {
const cursor = view.state.selection.from
const index = flatElements(view.state.doc).findLastIndex((e) => e.pos < cursor)
const pages = app.layout?.pages ?? 1
let page = 1
let label = ''
for (const br of app.layout?.breaks ?? []) {
if (br.element < index || (br.element === index && br.offset === 0)) [page, label] = [br.page, br.label]
}
return page > pages ? String(pages) : label || String(page)
}
/** Words of the printed script: notes, sections and synopses do not count. */
function wordCount(): number {
let words = 0
for (const { node } of flatElements(view.state.doc)) {
if (isPrinted(elementType(node))) words += printedText(node).match(/\S+/g)?.length ?? 0
}
return words
}
function markActive(type: MarkType): boolean {
const { from, $from, to, empty } = view.state.selection
if (empty) return !!type.isInSet(view.state.storedMarks || $from.marks())
return view.state.doc.rangeHasMark(from, to, type)
}
function updateStatus() {
const { $from } = view.state.selection
const type = elementType($from.parent)
const dot = `var(--t-${type})`
typeButton.style.setProperty('--dot', dot)
$('#type-label').textContent = LABELS[type]
const hudType = $('#st-type')
hudType.style.setProperty('--dot', dot)
hudType.textContent = LABELS[type]
$('#st-hint').textContent = `↵ ${LABELS[NEXT_ON_ENTER[type]]} ⇥ ${LABELS[TAB[type]]}`
const pages = app.layout?.pages ?? 1
$('#st-pages').textContent = `Page ${currentPage()} / ${pages}`
$('#st-words').textContent = `${wordCount().toLocaleString('en-US')} words`
renderRevisionState()
const toggles: [string, boolean][] = [
['bold', markActive(schema.marks.bold)],
['italic', markActive(schema.marks.italic)],
['underline', markActive(schema.marks.underline)],
['note', markActive(schema.marks.note)],
['dual', $from.depth > 1],
['spelling', app.spelling && !!app.spellLang],
['revisions', !!app.settings.revising || !!app.settings.locked],
]
for (const [action, on] of toggles) {
document.querySelector(`[data-action="${action}"]`)?.classList.toggle('on', on)
}
highlightScene()
renderSearchCount()
}
// ------------------------------------------------------------- revisions
/** HUD chip: the revision being marked and whether pages are locked. */
function renderRevisionState() {
const sets = app.settings.revisions ?? []
const chip = $('#st-rev')
const parts: string[] = []
const current = currentRevision()
if (current) parts.push(`${sets[sets.length - 1].name} Revision`)
if (app.settings.locked) parts.push('Pages Locked')
chip.textContent = parts.join(' · ')
chip.style.setProperty('--dot', current ? `var(--rev-${current.color})` : 'var(--muted)')
chip.hidden = !parts.length
$('#st-rev-sep').hidden = chip.hidden
}
/** Settings are not part of the editor's document: record the change. */
function settingsChanged() {
markChanged()
updateStatus()
paginate()
}
async function newRevision() {
const sets = app.settings.revisions ?? []
const set = await api.RevisionSet(sets.length + 1)
app.settings = { ...app.settings, revisions: [...sets, set], revising: true }
settingsChanged()
}
async function toggleLock() {
if (app.settings.locked) {
app.settings = { ...app.settings, locked: false }
view.dispatch(unlockPages(view.state))
settingsChanged()
return
}
// Lock the pages as they break now, from a fresh pagination.
const doc = view.state.doc
const info = await api.Paginate(toScript(doc, app.titlePage, { ...app.settings, locked: false }))
if (view.state.doc !== doc) return toggleLock()
app.settings = { ...app.settings, locked: true }
view.dispatch(lockPages(view.state, info.breaks ?? []))
settingsChanged()
}
async function clearMarks() {
const clear = await messageBox({
title: 'Clear revision marks?',
text: 'The revised text of every revision set will be shown and printed as original text. The revision sets are kept.',
ok: 'Clear Marks',
cancel: 'Cancel',
destructive: true,
})
if (clear) view.dispatch(clearRevisionMarks(view.state))
}
function revisionsMenu() {
const button = $('[data-action="revisions"]')
if (menuOpen()) return closeMenu()
const sets = app.settings.revisions ?? []
const next = sets.length ? 'New Revision Set' : 'Start Revisions'
const items: MenuItem[] = [
{
label: 'Mark Revisions',
checked: !!currentRevision(),
hint: sets.length ? sets[sets.length - 1].name : '',
action: () => {
if (!sets.length) return void newRevision().catch(reportError)
app.settings = { ...app.settings, revising: !app.settings.revising }
settingsChanged()
},
},
{ label: next, action: () => void newRevision().catch(reportError) },
{ separator: true },
]
for (const set of sets.slice().reverse()) {
items.push({ label: `${set.name} Revision`, hint: set.date, disabled: true })
}
if (!sets.length) items.push({ label: 'No revision sets', disabled: true })
items.push(
{ separator: true },
{ label: 'Lock Pages', checked: !!app.settings.locked, action: () => void toggleLock().catch(reportError) },
{ label: 'Clear Revision Marks…', disabled: !sets.length, action: () => void clearMarks().catch(reportError) },
)
const rect = button.getBoundingClientRect()
openMenu(items, rect.left, rect.bottom + 8, { from: button })
}
// ----------------------------------------------------------------- paper
const PAPER_NAMES = { '': 'US Letter', a4: 'A4' }
/** The page on screen has the width of the paper; Go paginates for it. */
function renderPaper() {
$('#paper').dataset.paper = app.settings.paper || 'letter'
}
// ---------------------------------------------------------------- search
const searchBar = $('#search')
const searchInput = $<HTMLInputElement>('#search-input')
const replaceInput = $<HTMLInputElement>('#replace-input')
const searchOptions = { caseSensitive: false, wholeWord: false }
const caseButton = searchBar.querySelector<HTMLButtonElement>('[data-search="case"]')!
const wordButton = searchBar.querySelector<HTMLButtonElement>('[data-search="word"]')!
function openSearch(replace: boolean) {
const { from, to, empty } = view.state.selection
const selected = empty ? '' : view.state.doc.textBetween(from, to, '\n')
if (selected && !selected.includes('\n')) searchInput.value = selected
if (replace || searchBar.hidden) searchBar.dataset.mode = replace ? 'replace' : 'find'
searchBar.hidden = false
updateSearch()
const input = replace && searchInput.value ? replaceInput : searchInput
input.focus()
input.select()
}
function closeSearch() {
searchBar.hidden = true
setSearch({ text: '', ...searchOptions })(view.state, view.dispatch)
view.focus()
}
/** Searches again after the query or an option changed, from the cursor. */
function updateSearch() {
setSearch({ text: searchInput.value, ...searchOptions })(view.state, view.dispatch)
selectMatch(1, true)(view.state, view.dispatch)
renderSearchCount()
}
function renderSearchCount() {
if (searchBar.hidden) return
const total = searchMatches(view.state).length
const current = activeMatch(view.state)
let label = ''
if (searchInput.value) label = !total ? 'No results' : current >= 0 ? `${current + 1} of ${total}` : `${total} found`
$('#search-count').textContent = label
searchBar.dataset.state = searchInput.value && !total ? 'none' : ''
caseButton.classList.toggle('on', searchOptions.caseSensitive)
wordButton.classList.toggle('on', searchOptions.wholeWord)
}
const searchActions: Record<string, () => void> = {
toggle: () => openSearch(searchBar.dataset.mode !== 'replace'),
case() {
searchOptions.caseSensitive = !searchOptions.caseSensitive
updateSearch()
},
word() {
searchOptions.wholeWord = !searchOptions.wholeWord
updateSearch()
},
previous: () => selectMatch(-1)(view.state, view.dispatch),
next: () => selectMatch(1)(view.state, view.dispatch),
close: closeSearch,
replace: () => replaceMatch(replaceInput.value)(view.state, view.dispatch),
replaceAll: () => replaceAll(replaceInput.value)(view.state, view.dispatch),
}
searchBar.querySelectorAll<HTMLButtonElement>('button[data-search]').forEach((btn) => {
btn.addEventListener('mousedown', (e) => e.preventDefault()) // keep the focus in the fields
btn.addEventListener('click', () => searchActions[btn.dataset.search!]())
})
searchInput.addEventListener('input', updateSearch)
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchActions[e.shiftKey ? 'previous' : 'next']()
else if (e.key === 'Escape') closeSearch()
else return
e.preventDefault()
})
replaceInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchActions[e.ctrlKey && e.altKey ? 'replaceAll' : 'replace']()
else if (e.key === 'Escape') closeSearch()
else return
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 {
title: string
text: string
ok: string
cancel?: string // omitted: a single-button notice
destructive?: boolean
}
/**
* In-app message box. The native GTK one only offers stock buttons
* (Yes/No, OK) translated to the system language.
*/
function messageBox(opts: MessageOptions): Promise<boolean> {
const dialog = $<HTMLDialogElement>('#message-dialog')
$('#message-title').textContent = opts.title
$('#message-text').textContent = opts.text
const ok = $<HTMLButtonElement>('#message-ok')
const cancel = $<HTMLButtonElement>('#message-cancel')
ok.textContent = opts.ok
ok.classList.toggle('destructive', !!opts.destructive)
cancel.textContent = opts.cancel ?? ''
cancel.hidden = !opts.cancel
dialog.returnValue = ''
typeMenu.hidden = true
return new Promise((resolve) => {
dialog.onclose = () => {
resolve(dialog.returnValue === 'ok')
view.focus()
}
dialog.showModal()
;(opts.cancel && opts.destructive ? cancel : ok).focus()
})
}
function reportError(err: unknown) {
console.error(err)
messageBox({ title: 'Something went wrong', text: String(err), ok: 'OK' })
}
async function confirmDiscard(): Promise<boolean> {
if (!app.dirty) return true
const discard = await messageBox({
title: 'Discard unsaved changes?',
text: 'This screenplay has changes that have not been saved. If you continue, they will be lost.',
ok: 'Discard',
cancel: 'Cancel',
destructive: true,
})
if (!discard) return false
if (!app.path) await api.ClearRecovery()
await api.SetDirty(false)
app.dirty = false
return true
}
// Closing the window with unsaved changes: Go keeps it open and asks us.
runtime.EventsOn('confirm-close', async () => {
if (document.querySelector('dialog[open]')) return
if (await confirmDiscard()) runtime.Quit()
})
const actions: Record<string, () => unknown> = {
async new() {
if (await confirmDiscard()) loadScript(await api.NewDocument(), '')
},
async open() {
if (!(await confirmDiscard())) return
const res = await api.Open()
if (res) loadScript(res.document, res.path)
},
save: () => saveTo(api.Save(script(), app.path)),
saveAs: () => saveTo(api.SaveAs(script(), app.path)),
pdf() {
const dialog = $<HTMLDialogElement>('#export-dialog')
const form = dialog.querySelector('form')!
const input = (name: string) => form.elements.namedItem(name) as HTMLInputElement
const paper = form.elements.namedItem('paper') as HTMLSelectElement
const opts = { titlePage: true, sceneNumbers: false, revisions: true, ...JSON.parse(load('export') ?? '{}') }
input('titlePage').checked = opts.titlePage
input('sceneNumbers').checked = opts.sceneNumbers
input('revisions').checked = opts.revisions
input('revisions').closest('label')!.hidden = !app.settings.revisions?.length
paper.value = app.settings.paper ?? ''
const describe = () => {
$('#export-paper').textContent = PAPER_NAMES[paper.value as '' | 'a4'] ?? 'US Letter'
}
paper.onchange = describe
describe()
dialog.onclose = async () => {
view.focus()
if (dialog.returnValue !== 'ok') return
const chosen = {
titlePage: input('titlePage').checked,
sceneNumbers: input('sceneNumbers').checked,
revisions: input('revisions').checked,
}
store('export', JSON.stringify(chosen))
// The paper size belongs to the script: it changes its pagination.
const size = paper.value === 'a4' ? 'a4' : ''
if (size !== (app.settings.paper ?? '')) {
app.settings = { ...app.settings, paper: size }
renderPaper()
settingsChanged()
}
await api.ExportPDF(script(), chosen, app.path).catch(reportError)
}
dialog.showModal()
},
async fdx() {
await api.ExportFDX(script(), app.path)
},
title() {
const dialog = $<HTMLDialogElement>('#title-dialog')
const form = dialog.querySelector('form')!
const fields = Object.keys(app.titlePage) as (keyof TitlePage)[]
for (const f of fields) {
const input = form.elements.namedItem(f) as HTMLInputElement | null
if (input) input.value = app.titlePage[f]
}
dialog.onclose = () => {
view.focus()
if (dialog.returnValue !== 'ok') return
let changed = false
for (const f of fields) {
const input = form.elements.namedItem(f) as HTMLInputElement | null
if (input && input.value !== app.titlePage[f]) {
app.titlePage[f] = input.value
changed = true
}
}
if (changed) markChanged()
}
dialog.showModal()
},
bold: () => toggleMark(schema.marks.bold)(view.state, view.dispatch),
italic: () => toggleMark(schema.marks.italic)(view.state, view.dispatch),
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')
store('navigator', document.body.classList.contains('no-navigator') ? 'off' : 'on')
},
focus: () => document.body.classList.toggle('focus-mode'),
find: () => openSearch(false),
replace: () => openSearch(true),
findNext: () => (searchBar.hidden ? openSearch(false) : searchActions.next()),
findPrevious: () => (searchBar.hidden ? openSearch(false) : searchActions.previous()),
spelling: spellingMenu,
toggleSpelling: () => setSpelling(!app.spelling),
zoomIn: () => setZoom(app.zoom + 0.1),
zoomOut: () => setZoom(app.zoom - 0.1),
zoomReset: () => setZoom(1),
}
async function run(name: string) {
try {
await actions[name]()
} catch (err) {
reportError(err)
}
if (!document.querySelector('dialog[open]') && !searchBar.contains(document.activeElement)) view.focus()
}
function setZoom(z: number) {
app.zoom = Math.round(Math.min(2, Math.max(0.6, z)) * 10) / 10
$('#paper').style.setProperty('zoom', String(app.zoom))
store('zoom', String(app.zoom))
}
document.querySelectorAll<HTMLButtonElement>('.tools button[data-action]').forEach((btn) => {
btn.addEventListener('mousedown', (e) => e.preventDefault()) // keep the editor's selection
btn.addEventListener('click', () => run(btn.dataset.action!))
})
const shortcuts: Record<string, string> = {
'ctrl+n': 'new',
'ctrl+o': 'open',
'ctrl+s': 'save',
'ctrl+shift+s': 'saveAs',
'ctrl+p': 'pdf',
'ctrl+shift+e': 'fdx',
'ctrl+shift+t': 'title',
'ctrl+\\': 'navigator',
'ctrl+shift+f': 'focus',
'ctrl+f': 'find',
'ctrl+h': 'replace',
'ctrl+g': 'findNext',
'ctrl+shift+g': 'findPrevious',
f3: 'findNext',
'shift+f3': 'findPrevious',
f7: 'toggleSpelling',
'ctrl+shift+l': 'translate',
'ctrl+=': 'zoomIn',
'ctrl++': 'zoomIn',
'ctrl+-': 'zoomOut',
'ctrl+0': 'zoomReset',
}
window.addEventListener(
'keydown',
(e) => {
if (!typeMenu.hidden) {
const n = ELEMENT_TYPES.length
const handled: Record<string, () => void> = {
ArrowDown: () => ((menuIndex = (menuIndex + 1) % n), renderTypeMenu()),
ArrowUp: () => ((menuIndex = (menuIndex - 1 + n) % n), renderTypeMenu()),
Enter: () => chooseType(ELEMENT_TYPES[menuIndex]),
Escape: () => ((typeMenu.hidden = true), view.focus()),
}
if (handled[e.key]) {
e.preventDefault()
e.stopPropagation()
handled[e.key]()
return
}
}
const fkey = /^F\d+$/.test(e.key)
if ((!e.ctrlKey && !fkey) || e.altKey) return
const shift = e.shiftKey && (fkey || /^[a-z]$/i.test(e.key))
const key = `${e.ctrlKey ? 'ctrl+' : ''}${shift ? 'shift+' : ''}${e.key.toLowerCase()}`
const action = shortcuts[key]
if (!action || document.querySelector('dialog[open]')) return
e.preventDefault()
e.stopPropagation()
run(action)
},
true,
)
// ---------------------------------------------------------- window frame
// The window has no system title bar: the top bar plays the part of a GTK
// header bar (drag to move, double-click to maximise, buttons laid out from
// the desktop's button layout, whatever the desktop).
const WINDOW_BUTTONS: Record<string, [string, () => void]> = {
minimize: ['Minimize', () => runtime.WindowMinimise()],
maximize: ['Maximize', () => runtime.WindowToggleMaximise()],
close: ['Close', () => runtime.Quit()],
}
async function setupWindowControls() {
const frame = await api.WindowFrame().catch(() => ({ buttons: 'appmenu:close', rounded: true }))
// Without a compositor the corners cannot be transparent: keep them square.
document.body.classList.toggle('opaque', !frame.rounded)
const [left = '', right = ''] = frame.buttons.split(':')
const fill = (el: HTMLElement, spec: string) => {
for (const name of spec.split(',').map((s) => s.trim())) {
const entry = WINDOW_BUTTONS[name]
if (!entry) continue
const btn = document.createElement('button')
btn.className = `win-button win-${name}`
btn.setAttribute('aria-label', entry[0])
btn.innerHTML = icon(name)
btn.addEventListener('mousedown', (e) => e.preventDefault())
btn.addEventListener('click', entry[1])
el.appendChild(btn)
}
el.hidden = !el.children.length
}
fill($('#win-left'), left)
fill($('#win-right'), right)
}
async function syncMaximised() {
const maximised = await runtime.WindowIsMaximised().catch(() => false)
document.body.classList.toggle('maximized', maximised)
const btn = document.querySelector('.win-maximize')
if (btn) btn.innerHTML = icon(maximised ? 'restore' : 'maximize')
}
$('#topbar').addEventListener('dblclick', (e) => {
if ((e.target as HTMLElement).closest('button, .menu')) return
runtime.WindowToggleMaximise()
})
window.addEventListener('resize', () => syncMaximised())
window.addEventListener('blur', () => document.body.classList.add('backdrop'))
window.addEventListener('focus', () => document.body.classList.remove('backdrop'))
// ------------------------------------------------------------------ boot
async function boot() {
const initial = await api.InitialFile()
if (initial) return loadScript(initial.document, initial.path)
const recovered = await api.Recovery()
if (!recovered) return paginate()
const restore = await messageBox({
title: 'Recover unsaved screenplay?',
text: 'An unsaved screenplay from your previous session was found. Do you want to restore it?',
ok: 'Restore',
cancel: 'Discard',
})
if (!restore) {
await api.ClearRecovery()
return paginate()
}
loadScript(recovered.document, '')
markChanged()
}
if (load('navigator') === 'off') document.body.classList.add('no-navigator')
setZoom(app.zoom)
renderPaper()
renderSaveState()
renderNavigator()
updateStatus()
view.focus()
setupWindowControls().then(syncMaximised)
setupSpelling().catch(console.error)
boot().catch(reportError)