feat: first release !

This commit is contained in:
Kharec committed 2026-09-28 14:21:06 +02:00
1 parent 77a333e8d9
commit 664527182d
66 files changed
+13812 -16

No files matched your search

View File
Whitespace-only changes.
+167
View File
@@ -0,0 +1,167 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Plotzilla</title>
</head>
<body>
<header class="topbar" id="topbar">
<div class="win-controls" id="win-left"></div>
<div class="brand"><span class="logo" data-icon="logo"></span>Plotzilla</div>
<div class="doc">
<span id="doc-name" class="doc-name">Untitled</span>
<span id="save-state" class="save-state"></span>
</div>
<div class="spacer"></div>
<nav class="tools">
<div class="group">
<button data-action="new" data-icon="new" data-tip="New · Ctrl+N"></button>
<button data-action="open" data-icon="open" data-tip="Open · Ctrl+O"></button>
<button data-action="save" data-icon="save" data-tip="Save · Ctrl+S"></button>
</div>
<div class="group">
<button id="type-button" class="type-button" data-tip="Element type · Ctrl+1…9">
<span class="type-dot"></span><span id="type-label">Action</span><span class="chev" data-icon="chevron"></span>
</button>
</div>
<div class="group">
<button data-action="bold" data-icon="bold" data-tip="Bold · Ctrl+B"></button>
<button data-action="italic" data-icon="italic" data-tip="Italic · Ctrl+I"></button>
<button data-action="underline" data-icon="underline" data-tip="Underline · Ctrl+U"></button>
<button data-action="note" data-icon="note" data-tip="Note · Ctrl+Shift+N"></button>
<button data-action="dual" data-icon="dual" data-tip="Dual dialogue · Ctrl+D"></button>
</div>
<div class="group">
<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>
</div>
<div class="group">
<button data-action="title" data-icon="title" data-tip="Title page · Ctrl+Shift+T"></button>
<button data-action="pdf" data-icon="pdf" data-tip="Export PDF · Ctrl+P"></button>
<button data-action="fdx" data-icon="fdx" data-tip="Export to Final Draft · Ctrl+Shift+E"></button>
</div>
<div class="group">
<button data-action="navigator" data-icon="navigator" data-tip="Scenes · Ctrl+\"></button>
<button data-action="focus" data-icon="focus" data-tip="Focus mode · Ctrl+Shift+F"></button>
</div>
</nav>
<div class="win-controls" id="win-right"></div>
</header>
<ul id="type-menu" class="menu" hidden></ul>
<div class="main">
<aside class="navigator">
<div class="nav-head">
<h2>Scenes</h2>
<span id="scene-count" class="count"></span>
</div>
<p id="scene-empty" class="empty">Type <kbd>INT.</kbd> or <kbd>EXT.</kbd> to start your first scene.</p>
<ol id="scene-list"></ol>
<div class="nav-foot">
<label class="switch">
<input type="checkbox" id="autosave-toggle" />
<span class="track"><span class="thumb"></span></span>
Autosave
</label>
</div>
</aside>
<div class="workspace">
<div class="scroller" id="scroller">
<div class="paper" id="paper">
<div id="editor"></div>
</div>
</div>
<div class="search" id="search" data-mode="find" hidden>
<button class="search-toggle" data-search="toggle" data-icon="expand" data-tip="Replace · Ctrl+H"></button>
<div class="search-fields">
<div class="search-row">
<input id="search-input" placeholder="Find" autocomplete="off" spellcheck="false" />
<span id="search-count" class="search-count"></span>
<button data-search="case" data-icon="matchCase" data-tip="Match case"></button>
<button data-search="word" data-icon="wholeWord" data-tip="Whole words"></button>
<span class="sep"></span>
<button data-search="previous" data-icon="up" data-tip="Previous · Shift+Enter"></button>
<button data-search="next" data-icon="down" data-tip="Next · Enter"></button>
<button data-search="close" data-icon="close" data-tip="Close · Esc"></button>
</div>
<div class="search-row replace-row">
<input id="replace-input" placeholder="Replace with" autocomplete="off" spellcheck="false" />
<button class="text-button" data-search="replace" data-tip="Replace · Enter">Replace</button>
<button class="text-button" data-search="replaceAll" data-tip="Replace all · Ctrl+Alt+Enter">All</button>
</div>
</div>
</div>
<div class="hud">
<span id="st-type" class="hud-type"></span>
<span id="st-hint" class="hud-hint"></span>
<span class="sep"></span>
<span id="st-pages"></span>
<span class="sep"></span>
<span id="st-words"></span>
<span id="st-rev-sep" class="sep" hidden></span>
<span id="st-rev" class="hud-rev" hidden></span>
</div>
</div>
</div>
<dialog id="title-dialog">
<form method="dialog">
<h2>Title Page</h2>
<label>Title <input name="title" autocomplete="off" /></label>
<div class="row">
<label>Credit <input name="credit" placeholder="Written by" autocomplete="off" /></label>
<label>Author(s) <input name="author" autocomplete="off" /></label>
</div>
<label>Source <input name="source" placeholder="Based on the novel by…" autocomplete="off" /></label>
<div class="row">
<label>Contact <textarea name="contact" rows="4"></textarea></label>
<label>Draft date <textarea name="draftDate" rows="2"></textarea>
<span class="sub">Notes</span><textarea name="notes" rows="1"></textarea>
</label>
</div>
<menu>
<button value="cancel" formnovalidate>Cancel</button>
<button value="ok" class="primary">Done</button>
</menu>
</form>
</dialog>
<dialog id="export-dialog">
<form method="dialog">
<h2>Export PDF</h2>
<p class="muted"><span id="export-paper">US Letter</span> · Courier 12 pt · standard margins</p>
<label class="check"><input type="checkbox" name="titlePage" checked /> Include title page</label>
<label class="check"><input type="checkbox" name="sceneNumbers" /> Scene numbers (shooting script)</label>
<label class="check"><input type="checkbox" name="revisions" checked /> Revision marks and page headers</label>
<label class="inline">Paper
<select name="paper">
<option value="">US Letter</option>
<option value="a4">A4</option>
</select>
</label>
<menu>
<button value="cancel" formnovalidate>Cancel</button>
<button value="ok" class="primary">Export…</button>
</menu>
</form>
</dialog>
<dialog id="message-dialog" class="message">
<form method="dialog">
<h2 id="message-title"></h2>
<p id="message-text"></p>
<menu>
<button value="cancel" id="message-cancel" formnovalidate></button>
<button value="ok" id="message-ok" class="primary"></button>
</menu>
</form>
</dialog>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1304
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "plotzilla-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"prosemirror-commands": "^1.6.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.1"
},
"devDependencies": {
"typescript": "^7.0.2",
"vite": "^8.3.1"
}
}
View File
Whitespace-only changes.
+199
View File
@@ -0,0 +1,199 @@
// Inline suggestions for character cues, scene headings and transitions,
// drawn from what the script already contains.
import { EditorState, Plugin } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { blockText, elementType, flatElements, printedText } from './schema'
const SCENE_PREFIXES = ['INT. ', 'EXT. ', 'INT./EXT. ', 'EST. ']
const TIMES = ['DAY', 'NIGHT', 'CONTINUOUS', 'LATER', 'MOMENTS LATER', 'MORNING', 'EVENING', 'DUSK', 'DAWN', 'SAME']
const EXTENSIONS = ["V.O.", 'O.S.', 'O.C.', "CONT'D"]
const TRANSITIONS = ['CUT TO:', 'FADE OUT.', 'FADE TO:', 'DISSOLVE TO:', 'SMASH CUT TO:', 'MATCH CUT TO:', 'JUMP CUT TO:', 'FADE TO BLACK.']
const SHOTS = ['INSERT - ', 'CLOSE ON ', 'ANGLE ON ', 'POV - ', 'WIDE ON ', 'EXTREME CLOSE UP ON ', 'BACK TO SCENE']
const PREFIX_RE = /^(INT\.\/EXT\.|INT\/EXT|I\/E\.?|INT\.|EXT\.|EST\.)\s*/
interface Suggestions {
items: string[]
from: number
to: number
key: string
}
function byFrequency(values: string[], exclude: string): string[] {
const counts = new Map<string, number>()
for (const v of values) if (v && v !== exclude) counts.set(v, (counts.get(v) ?? 0) + 1)
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([v]) => v)
}
function collect(state: EditorState, skip: number): { names: string[]; locations: string[]; times: string[] } {
const names: string[] = []
const locations: string[] = []
const times: string[] = []
flatElements(state.doc).forEach(({ node, pos }) => {
if (pos === skip) return
const text = printedText(node).toUpperCase().trim()
const type = elementType(node)
if (type === 'character') {
names.push(text.replace(/\^$/, '').replace(/\(.*$/, '').trim())
} else if (type === 'scene') {
const rest = text.replace(PREFIX_RE, '')
const dash = rest.lastIndexOf(' - ')
locations.push((dash >= 0 ? rest.slice(0, dash) : rest).trim())
if (dash >= 0) times.push(rest.slice(dash + 3).trim())
}
})
return { names, locations, times }
}
function compute(state: EditorState): Suggestions | null {
const { $from, empty } = state.selection
const node = $from.parent
if (!empty || $from.parentOffset !== node.content.size) return null
const type = elementType(node)
const raw = blockText(node)
const text = raw.toUpperCase()
const start = $from.start()
const skip = $from.before()
const make = (items: string[], offset: number): Suggestions | null =>
items.length ? { items: items.slice(0, 8), from: start + offset, to: start + raw.length, key: `${skip}:${raw}` } : null
if (type === 'character' && text.trim()) {
const paren = text.lastIndexOf('(')
if (paren >= 0 && !text.includes(')', paren)) {
const typed = text.slice(paren + 1)
return make(
EXTENSIONS.filter((e) => e.startsWith(typed) && e !== typed).map((e) => e + ')'),
paren + 1,
)
}
const names = byFrequency(collect(state, skip).names, '')
return make(names.filter((n) => n.startsWith(text) && n !== text), 0)
}
if (type === 'scene' && text.trim()) {
const prefix = text.match(PREFIX_RE)
if (!prefix) {
return make(SCENE_PREFIXES.filter((p) => p.startsWith(text) && p !== text), 0)
}
const rest = text.slice(prefix[0].length)
const dash = rest.lastIndexOf(' - ')
const { locations, times } = collect(state, skip)
if (dash < 0) {
if (!rest) return null
const locs = byFrequency(locations, '').filter((l) => l.startsWith(rest) && l !== rest)
return make(locs.map((l) => l + ' - '), prefix[0].length)
}
const typed = rest.slice(dash + 3)
const all = [...new Set([...TIMES, ...byFrequency(times, '')])]
return make(all.filter((t) => t.startsWith(typed) && t !== typed), prefix[0].length + dash + 3)
}
if (type === 'shot' && text.trim()) {
return make(SHOTS.filter((s) => s.startsWith(text) && s !== text), 0)
}
if (type === 'transition' && text.trim()) {
return make(TRANSITIONS.filter((t) => t.startsWith(text) && t !== text), 0)
}
return null
}
export function autocompletePlugin(): Plugin {
const popup = document.createElement('ul')
popup.className = 'autocomplete'
popup.hidden = true
let current: Suggestions | null = null
let index = 0
let dismissed = ''
const render = (view: EditorView) => {
if (!current || !view.hasFocus()) {
popup.hidden = true
return
}
popup.replaceChildren(
...current.items.map((item, i) => {
const li = document.createElement('li')
li.textContent = item
li.classList.toggle('active', i === index)
li.addEventListener('mousedown', (e) => {
e.preventDefault()
index = i
accept(view)
})
return li
}),
)
const coords = view.coordsAtPos(current.from)
popup.style.left = `${coords.left}px`
popup.style.top = `${coords.bottom + 4}px`
popup.hidden = false
}
const accept = (view: EditorView) => {
if (!current) return
const { from, to, items } = current
view.dispatch(view.state.tr.insertText(items[index], from, to).scrollIntoView())
}
// The popup is position: fixed, so it must follow the script when it scrolls.
let currentView: EditorView | null = null
const reposition = () => {
if (currentView && !popup.hidden) render(currentView)
}
return new Plugin({
view(view) {
currentView = view
document.body.appendChild(popup)
document.addEventListener('scroll', reposition, true)
return {
update(view) {
const next = compute(view.state)
if (next && next.key === dismissed) current = null
else {
if (!current || !next || next.key !== current.key) index = 0
current = next
}
render(view)
},
destroy() {
document.removeEventListener('scroll', reposition, true)
currentView = null
popup.remove()
},
}
},
props: {
handleKeyDown(view, event) {
if (!current || popup.hidden) return false
switch (event.key) {
case 'ArrowDown':
index = (index + 1) % current.items.length
render(view)
return true
case 'ArrowUp':
index = (index - 1 + current.items.length) % current.items.length
render(view)
return true
case 'Enter':
case 'Tab':
if (event.shiftKey) return false
accept(view)
return true
case 'Escape':
dismissed = current.key
current = null
render(view)
return true
}
return false
},
handleDOMEvents: {
blur() {
popup.hidden = true
return false
},
},
},
})
}
+175
View File
@@ -0,0 +1,175 @@
// Typed access to the Go methods bound by Wails (window.go.main.App) and to
// the Wails runtime.
export type ElementType =
| 'scene'
| 'action'
| 'character'
| 'parenthetical'
| 'dialogue'
| 'transition'
| 'shot'
| 'intertitle'
| 'centered'
| 'section'
| 'synopsis'
export interface Run {
text: string
b?: boolean
i?: boolean
u?: boolean
/** A writer's note: shown in the editor, never printed. */
n?: boolean
/** Revision set (from 1) in which the text was added or changed. */
rev?: number
}
/** A locked page starting at a rune offset of the element's text. */
export interface PageLock {
offset: number
page: string
}
export interface Element {
type: ElementType
runs: Run[] | null
sceneNumber?: string
dual?: boolean
/** Depth of a section, from 1. */
level?: number
/** Forced page break before the element (Fountain "==="). */
newPage?: boolean
locked?: PageLock[] | null
}
export interface TitlePage {
title: string
credit: string
author: string
source: string
draftDate: string
contact: string
notes: string
}
export interface Revision {
name: string // "Blue", "Double Pink"
color: string // "blue", "pink"…
date: string
}
/** Production settings (screenplay.Settings); every field may be missing. */
export interface Settings {
paper?: '' | 'a4'
revisions?: Revision[] | null
revising?: boolean
locked?: boolean
}
export interface ScriptDocument {
titlePage: TitlePage
elements: Element[]
settings?: Settings
}
export interface OpenResult {
path: string
document: ScriptDocument
}
export interface PageBreak {
element: number
offset: number
page: number
/** The printed page number: "12", "12A", "12-13". */
label: string
}
export interface ScenePage {
element: number
number: string
page: number
label: string
}
export interface LayoutInfo {
pages: number
breaks: PageBreak[] | null
scenes: ScenePage[] | null
}
/** A misspelled word: element index as in PageBreak, rune offset and length. */
export interface Misspelling {
element: number
offset: number
length: number
word: string
}
export interface SpellInfo {
languages: string[] | null
default: string
}
/** How to draw the window frame (see WindowFrame in app.go). */
export interface WindowFrame {
/** GTK decoration layout, e.g. "appmenu:minimize,maximize,close". */
buttons: string
/** False without a compositor: the window cannot have rounded corners. */
rounded: boolean
}
export interface ExportOptions {
sceneNumbers: boolean
titlePage: boolean
revisions: boolean
}
interface GoApp {
NewDocument(): Promise<ScriptDocument>
InitialFile(): Promise<OpenResult | null>
Open(): Promise<OpenResult | null>
Save(doc: ScriptDocument, path: string): Promise<string>
SaveAs(doc: ScriptDocument, current: string): Promise<string>
ExportPDF(doc: ScriptDocument, opts: ExportOptions, current: string): Promise<string>
ExportFDX(doc: ScriptDocument, current: string): Promise<string>
Paginate(doc: ScriptDocument): Promise<LayoutInfo>
ParseFountain(text: string): Promise<Element[] | null>
SetDirty(dirty: boolean): Promise<void>
SaveRecovery(doc: ScriptDocument): Promise<void>
ClearRecovery(): Promise<void>
Recovery(): Promise<OpenResult | null>
WindowFrame(): Promise<WindowFrame>
RevisionSet(n: number): Promise<Revision>
SpellLanguages(): Promise<SpellInfo>
SpellCheck(doc: ScriptDocument, lang: string): Promise<Misspelling[] | null>
SpellSuggest(word: string, lang: string): Promise<string[] | null>
AddToDictionary(word: string): Promise<void>
}
interface WailsRuntime {
WindowSetTitle(title: string): void
WindowMinimise(): void
WindowToggleMaximise(): void
WindowIsMaximised(): Promise<boolean>
EventsOn(event: string, callback: (...data: unknown[]) => void): () => void
Quit(): void
}
declare global {
interface Window {
go: { main: { App: GoApp } }
runtime: WailsRuntime
}
}
export const api = new Proxy({} as GoApp, {
get: (_, name: string) => (...args: unknown[]) =>
(window.go.main.App as unknown as Record<string, (...a: unknown[]) => unknown>)[name](...args),
})
export const runtime = new Proxy({} as WailsRuntime, {
get: (_, name: string) => (...args: unknown[]) =>
(window.runtime as unknown as Record<string, (...a: unknown[]) => unknown>)[name](...args),
})
+396
View File
@@ -0,0 +1,396 @@
// Screenplay editing behaviour, modelled on Final Draft / Arc Studio:
// Enter moves to the element that naturally follows, Tab cycles the current
// element's type, and typing is auto-detected where it is unambiguous.
import { Command, EditorState, Plugin, TextSelection, Transaction } from 'prosemirror-state'
import { Node as PMNode } from 'prosemirror-model'
import { toggleMark } from 'prosemirror-commands'
import { undo, redo } from 'prosemirror-history'
import type { ElementType } from './backend'
import { ELEMENT_TYPES, blockText, dualNode, elementType, schema } from './schema'
/** Element created by Enter at the end of a non-empty element. */
export const NEXT_ON_ENTER: Record<ElementType, ElementType> = {
scene: 'action',
action: 'action',
character: 'dialogue',
parenthetical: 'dialogue',
dialogue: 'action',
transition: 'scene',
shot: 'action',
intertitle: 'action',
centered: 'action',
section: 'scene',
synopsis: 'action',
}
/** Enter on an empty element changes its type instead of adding a line. */
const RETYPE_ON_EMPTY_ENTER: Record<ElementType, ElementType> = {
scene: 'action',
action: 'scene',
character: 'action',
parenthetical: 'dialogue',
dialogue: 'action',
transition: 'action',
shot: 'action',
intertitle: 'action',
centered: 'action',
section: 'action',
synopsis: 'action',
}
export const TAB: Record<ElementType, ElementType> = {
action: 'character',
character: 'transition',
transition: 'scene',
scene: 'action',
dialogue: 'parenthetical',
parenthetical: 'dialogue',
shot: 'action',
intertitle: 'action',
centered: 'action',
section: 'action',
synopsis: 'action',
}
const SHIFT_TAB: Record<ElementType, ElementType> = {
action: 'scene',
scene: 'transition',
transition: 'character',
character: 'action',
dialogue: 'character',
parenthetical: 'dialogue',
shot: 'action',
intertitle: 'action',
centered: 'action',
section: 'action',
synopsis: 'action',
}
const SCENE_PREFIX = /^(int|ext|est|int\.?\/ext|i\/e)\.?\s$/i
const SHOT_PREFIX = /^insert(\s-|:)?\s$/i
// Fountain's outline markers, typed at the start of an empty line.
const SECTION_PREFIX = /^(#{1,6}) $/
const SYNOPSIS_PREFIX = /^= $/
const TRANSITION = /^[^a-z]+ TO:$/
function isBlank(node: PMNode): boolean {
const text = blockText(node)
return text.trim() === '' || (elementType(node) === 'parenthetical' && text.trim() === '()')
}
/**
* Changes the type of the element at pos, adding or removing the
* parentheses that a parenthetical carries.
*/
export function retype(tr: Transaction, pos: number, type: ElementType): Transaction {
const node = tr.doc.nodeAt(pos)!
const old = elementType(node)
if (old === type) return tr
tr.setNodeMarkup(pos, undefined, { ...node.attrs, type })
const text = blockText(node)
const start = pos + 1
const end = start + node.content.size
if (type === 'parenthetical') {
if (!text.startsWith('(')) {
tr.insertText(')', end)
tr.insertText('(', start)
const size = tr.doc.nodeAt(pos)!.content.size
tr.setSelection(TextSelection.create(tr.doc, start + size - 1))
}
} else if (old === 'parenthetical') {
if (text.endsWith(')')) tr.delete(end - 1, end)
if (text.startsWith('(')) tr.delete(start, start + 1)
}
return tr
}
/**
* Replaces the text between from and to (inside one element), keeping the
* bold, italic and underline of the text it replaces.
*/
export function replaceText(tr: Transaction, from: number, to: number, text: string): Transaction {
if (!text) return tr.delete(from, to)
const marks = tr.doc.nodeAt(from)?.marks ?? []
return tr.replaceWith(from, to, schema.text(text, marks))
}
export function setType(type: ElementType): Command {
return (state, dispatch) => {
const { $from, $to } = state.selection
if (dispatch) {
const tr = state.tr
if ($from.sameParent($to)) {
retype(tr, $from.before(), type)
} else {
state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type === schema.nodes.element) {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, type })
return false
}
return true
})
}
dispatch(tr.scrollIntoView())
}
return true
}
}
function cycle(map: Record<ElementType, ElementType>): Command {
return (state, dispatch) => {
const type = elementType(state.selection.$from.parent)
return setType(map[type])(state, dispatch)
}
}
export const enter: Command = (state, dispatch) => {
const tr = state.tr
if (!state.selection.empty) tr.deleteSelection()
const $pos = tr.selection.$from
const node = $pos.parent
const pos = $pos.before()
let type = elementType(node)
// Inside a dual dialogue, finishing a column moves on: the left column
// hands over to the right one, the right column exits below the block.
const colDepth = $pos.depth - 1
if ($pos.node(colDepth).type === schema.nodes.column) {
const text = blockText(node)
const atEnd =
$pos.parentOffset === node.content.size ||
(type === 'parenthetical' && text.endsWith(')') && $pos.parentOffset === node.content.size - 1)
const blank = isBlank(node)
if (blank || (atEnd && type === 'dialogue')) {
if (dispatch) {
const dual = $pos.node(colDepth - 1)
const dualPos = $pos.before(colDepth - 1)
const isRight = $pos.index(colDepth - 1) === 1
const rightStart = dualPos + 1 + dual.child(0).nodeSize + 1 // first element of the right column
const dualEnd = dualPos + dual.nodeSize
if (blank && $pos.node(colDepth).childCount > 1) tr.delete(pos, pos + node.nodeSize)
if (isRight) {
const at = tr.mapping.map(dualEnd)
tr.insert(at, schema.nodes.element.create({ type: 'action' }))
tr.setSelection(TextSelection.create(tr.doc, at + 1))
} else {
const at = tr.mapping.map(rightStart)
tr.setSelection(TextSelection.create(tr.doc, at + 1 + tr.doc.nodeAt(at)!.content.size))
}
dispatch(tr.scrollIntoView())
}
return true
}
}
if (isBlank(node)) {
if (dispatch) {
if (type === 'parenthetical') tr.delete(pos + 1, pos + 1 + node.content.size)
dispatch(retype(tr, pos, RETYPE_ON_EMPTY_ENTER[type]).scrollIntoView())
}
return true
}
const text = blockText(node)
if (type === 'action' && TRANSITION.test(text)) {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, type: 'transition' })
type = 'transition'
}
// Inside a parenthetical, the closing ")" counts as the end.
const atEnd =
$pos.parentOffset === node.content.size ||
(type === 'parenthetical' && text.endsWith(')') && $pos.parentOffset === node.content.size - 1)
if (dispatch) {
if (atEnd) {
const after = pos + node.nodeSize
tr.insert(after, schema.nodes.element.create({ type: NEXT_ON_ENTER[type] }))
tr.setSelection(TextSelection.create(tr.doc, after + 1))
} else {
// The second half never inherits a forced page break.
tr.split($pos.pos, 1, [{ type: schema.nodes.element, attrs: { type, sceneNumber: '', level: node.attrs.level } }])
}
dispatch(tr.scrollIntoView())
}
return true
}
const inDialogue = (t: ElementType | null) => t === 'parenthetical' || t === 'dialogue'
/** Top-level child range [start, end) of the dialogue block containing index. */
function dialogueBlockAt(doc: PMNode, index: number): [number, number] | null {
const t = (i: number) => {
const n = doc.child(i)
return n.type === schema.nodes.element ? elementType(n) : null
}
let start = index
while (start > 0 && inDialogue(t(start))) start--
if (t(start) !== 'character') return null
let end = start + 1
while (end < doc.childCount && inDialogue(t(end))) end++
return [start, end]
}
/**
* Ctrl+D: puts the dialogue under the cursor side by side with the next (or
* previous) one. Without a neighbour, opens an empty right column. Inside a
* dual dialogue, splits it back into two blocks.
*/
export const toggleDual: Command = (state, dispatch) => {
const { $from } = state.selection
const doc = state.doc
for (let d = $from.depth; d > 0; d--) {
const dual = $from.node(d)
if (dual.type !== schema.nodes.dual) continue
if (dispatch) {
const pos = $from.before(d)
const inRight = $from.index(d) === 1
const tr = state.tr.replaceWith(pos, pos + dual.nodeSize, dual.child(0).content.append(dual.child(1).content))
tr.setSelection(TextSelection.create(tr.doc, $from.pos - (inRight ? 4 : 2)))
dispatch(tr.scrollIntoView())
}
return true
}
if ($from.depth !== 1) return false
const cur = dialogueBlockAt(doc, $from.index(0))
if (!cur) return false
let left = cur
let right: [number, number] | null = null
const next = cur[1] < doc.childCount ? dialogueBlockAt(doc, cur[1]) : null
const prev = cur[0] > 0 ? dialogueBlockAt(doc, cur[0] - 1) : null
if (next && next[0] === cur[1]) right = next
else if (prev && prev[1] === cur[0]) [left, right] = [prev, cur]
if (dispatch) {
const posOf = (i: number) => {
let p = 0
for (let k = 0; k < i; k++) p += doc.child(k).nodeSize
return p
}
const nodes = ([a, b]: [number, number]) => Array.from({ length: b - a }, (_, k) => doc.child(a + k))
const from = posOf(left[0])
const mid = posOf(left[1])
const to = posOf((right ?? left)[1])
const rightNodes = right ? nodes(right) : [schema.nodes.element.create({ type: 'character' })]
const tr = state.tr.replaceWith(from, to, dualNode(nodes(left), rightNodes))
// dual + column open before the left content; +2 more for the right column.
const sel = !right ? mid + 5 : $from.pos + ($from.pos >= mid ? 4 : 2)
tr.setSelection(TextSelection.create(tr.doc, sel))
dispatch(tr.scrollIntoView())
}
return true
}
/**
* Ctrl+Enter: forces a page break at the cursor, like "===" in Fountain. At
* the start of an element (or on an empty one) it toggles the break before
* it; elsewhere it splits the element, or opens the next one, on a new page.
* In a dialogue block, or a dual dialogue, it toggles the break before the
* whole block: dialogue is never split by hand.
*/
export const pageBreak: Command = (state, dispatch) => {
const { $from } = state.selection
if ($from.parent.type !== schema.nodes.element) return false
const doc = state.doc
let pos = $from.before()
let splittable = $from.depth === 1
if ($from.depth > 1) {
pos = $from.before(1) + 2 // first element of the left column
splittable = false
} else if (inDialogue(elementType($from.parent))) {
const block = dialogueBlockAt(doc, $from.index(0))
if (block) {
pos = 0
for (let k = 0; k < block[0]; k++) pos += doc.child(k).nodeSize
splittable = false
}
}
const node = doc.nodeAt(pos)!
if (dispatch) {
const tr = state.tr
if (!state.selection.empty) tr.deleteSelection()
const $pos = tr.selection.$from
const type = elementType(node)
if (splittable && $pos.parentOffset > 0 && !isBlank(node)) {
if ($pos.parentOffset === $pos.parent.content.size) {
const after = pos + $pos.parent.nodeSize
tr.insert(after, schema.nodes.element.create({ type: NEXT_ON_ENTER[type], newPage: true }))
tr.setSelection(TextSelection.create(tr.doc, after + 1))
} else {
const attrs = { type, sceneNumber: '', level: node.attrs.level, newPage: true }
tr.split($pos.pos, 1, [{ type: schema.nodes.element, attrs }])
}
} else {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, newPage: !node.attrs.newPage })
}
dispatch(tr.scrollIntoView())
}
return true
}
const hardBreak: Command =(state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView())
return true
}
export function screenplayKeys(): Record<string, Command> {
const keys: Record<string, Command> = {
Enter: enter,
'Shift-Enter': hardBreak,
'Mod-Enter': pageBreak,
Tab: cycle(TAB),
'Shift-Tab': cycle(SHIFT_TAB),
'Mod-b': toggleMark(schema.marks.bold),
'Mod-i': toggleMark(schema.marks.italic),
'Mod-u': toggleMark(schema.marks.underline),
'Mod-Shift-n': toggleMark(schema.marks.note),
'Mod-z': undo,
'Mod-Shift-z': redo,
'Mod-y': redo,
'Mod-d': toggleDual,
}
ELEMENT_TYPES.slice(0, 9).forEach((type, i) => {
keys[`Mod-${i + 1}`] = setType(type)
})
return keys
}
/**
* Auto-detection while typing: "INT. " in an action becomes a scene heading,
* "INSERT " an insert, "# " (or "## "…) a section and "= " a synopsis, and "("
* at the start of an empty dialogue opens a parenthetical.
*/
export function autoFormatPlugin(): Plugin {
return new Plugin({
props: {
handleTextInput(view, from, to, text) {
const { $from } = view.state.selection
const node = $from.parent
if (text === '(' && elementType(node) === 'dialogue' && node.content.size === 0 && from === to) {
view.dispatch(retype(view.state.tr, $from.before(), 'parenthetical').scrollIntoView())
return true
}
return false
},
},
appendTransaction(trs, _old, state: EditorState) {
if (!trs.some((t) => t.docChanged)) return null
const { $from } = state.selection
const node = $from.parent
if (node.type !== schema.nodes.element) return null
const current = elementType(node)
const text = blockText(node)
if (current === 'action' || current === 'scene') {
const section = SECTION_PREFIX.exec(text)
if (section || SYNOPSIS_PREFIX.test(text)) {
const pos = $from.before()
const attrs = { ...node.attrs, type: section ? 'section' : 'synopsis', level: section?.[1].length ?? 1 }
return state.tr.setNodeMarkup(pos, undefined, attrs).delete(pos + 1, pos + 1 + node.content.size)
}
}
if (current !== 'action') return null
const type = SCENE_PREFIX.test(text) ? 'scene' : SHOT_PREFIX.test(text) ? 'shot' : null
if (!type) return null
return state.tr.setNodeMarkup($from.before(), undefined, { ...node.attrs, type })
},
})
}
+94
View File
@@ -0,0 +1,94 @@
// View decorations: live page breaks computed by the Go layout engine, and a
// placeholder naming the type of the empty element under the cursor.
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { Node as PMNode } from 'prosemirror-model'
import type { PageBreak } from './backend'
import { LABELS, blockText, elementType, flatElements, schema, utf16Offset } from './schema'
export { utf16Offset }
export const pagesKey = new PluginKey<DecorationSet>('pages')
function pageWidget(page: string, inline: boolean, type: string, forced = false): HTMLElement {
const el = document.createElement(inline ? 'span' : 'div')
el.className = `page-break${inline ? ` inline in-${type}` : ''}${forced ? ' forced' : ''}`
el.contentEditable = 'false'
if (forced) {
const tag = document.createElement('span')
tag.className = 'page-forced'
tag.textContent = 'Page break'
el.appendChild(tag)
}
const num = document.createElement('span')
num.className = 'page-number'
num.textContent = `${page}.`
el.appendChild(num)
return el
}
export function buildPageDecorations(doc: PMNode, breaks: PageBreak[]): DecorationSet {
const flat = flatElements(doc)
const decos: Decoration[] = []
for (const br of breaks) {
const entry = flat[br.element]
if (!entry) continue
const { node, pos, dualPos } = entry
const type = elementType(node)
const label = br.label || String(br.page)
// A forced break (Ctrl+Enter) says so; the dual dialogue's is on its first element.
const forced = br.offset === 0 && !!(dualPos !== null ? doc.nodeAt(dualPos + 2)?.attrs.newPage : node.attrs.newPage)
const key = `pb${br.page}${forced ? 'f' : ''}`
if (dualPos !== null) {
// A dual dialogue is never split: the page starts above the whole block.
decos.push(Decoration.widget(dualPos, () => pageWidget(label, false, type, forced), { side: -1, key }))
} else if (br.offset === 0) {
decos.push(Decoration.widget(pos, () => pageWidget(label, false, type, forced), { side: -1, key }))
} else {
const at = pos + 1 + utf16Offset(blockText(node), br.offset)
decos.push(
Decoration.widget(at, () => pageWidget(label, true, type), {
side: -1,
key: `pb${br.page}-${type}`,
ignoreSelection: true,
}),
)
}
}
return DecorationSet.create(doc, decos)
}
export function pagesPlugin(): Plugin<DecorationSet> {
return new Plugin<DecorationSet>({
key: pagesKey,
state: {
init: () => DecorationSet.empty,
apply(tr, set) {
const fresh = tr.getMeta(pagesKey) as DecorationSet | undefined
return fresh ?? set.map(tr.mapping, tr.doc)
},
},
props: {
decorations: (state) => pagesKey.getState(state),
},
})
}
export function placeholderPlugin(): Plugin {
return new Plugin({
props: {
decorations(state) {
const { $from } = state.selection
const node = $from.parent
if (node.type !== schema.nodes.element || node.content.size > 0) return null
const pos = $from.before()
return DecorationSet.create(state.doc, [
Decoration.node(pos, pos + node.nodeSize, {
class: 'is-empty',
'data-placeholder': LABELS[elementType(node)],
}),
])
},
},
})
}
+36
View File
@@ -0,0 +1,36 @@
// Stroke icons in the Lucide style (24×24 grid, ISC licence).
const paths: Record<string, string> = {
logo: '<path d="M4 11v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8H4Z"/><path d="m4 11-.88-2.87a2 2 0 0 1 1.33-2.5l11.48-3.5a2 2 0 0 1 2.5 1.32l.87 2.87L4 11.01Z"/><path d="m6.6 4.99 3.38 4.2"/><path d="m11.86 3.38 3.38 4.2"/>',
new: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/>',
open: '<path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2"/>',
save: '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
bold: '<path d="M6 12h9a4 4 0 0 1 0 8H6z"/><path d="M6 4h8a4 4 0 0 1 0 8H6z"/>',
italic: '<path d="M19 4h-9"/><path d="M14 20H5"/><path d="M15 4 9 20"/>',
underline: '<path d="M6 4v6a6 6 0 0 0 12 0V4"/><path d="M4 20h16"/>',
dual: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/><path d="M6 9h3M6 13h3M15 9h3M15 13h3"/>',
title: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
pdf: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
fdx: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="m16 6-4-4-4 4"/><path d="M12 2v13"/>',
navigator: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/>',
focus: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
replace: '<path d="M14 4a2 2 0 0 1 2-2"/><path d="M16 10a2 2 0 0 1-2-2"/><path d="M20 2a2 2 0 0 1 2 2"/><path d="M22 8a2 2 0 0 1-2 2"/><path d="m3 7 3 3 3-3"/><path d="M6 10V5a3 3 0 0 1 3-3h1"/><rect x="2" y="14" width="8" height="8" rx="2"/>',
matchCase: '<path d="m3 15 4-8 4 8"/><path d="M4 13h6"/><circle cx="18" cy="12" r="3"/><path d="M21 9v6"/>',
wholeWord: '<circle cx="7" cy="12" r="3"/><path d="M10 9v6"/><circle cx="17" cy="12" r="3"/><path d="M14 7v8"/><path d="M22 17v1c0 .5-.5 1-1 1H3c-.5 0-1-.5-1-1v-1"/>',
up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
expand: '<path d="m9 18 6-6-6-6"/>',
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"/>',
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"/>',
minimize: '<path d="M7 16h10"/>',
maximize: '<rect x="6.5" y="6.5" width="11" height="11" rx="1.5"/>',
restore: '<rect x="5.5" y="9" width="9.5" height="9.5" rx="1.5"/><path d="M9 9V7a1.5 1.5 0 0 1 1.5-1.5H17A1.5 1.5 0 0 1 18.5 7v6.5A1.5 1.5 0 0 1 17 15h-2"/>',
}
export function icon(name: string): string {
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] ?? ''}</svg>`
}
+1050
View File
File diff suppressed because it is too large. Load diff
+124
View File
@@ -0,0 +1,124 @@
// Popup menus opened at a point (context menu) or under a button, with
// keyboard navigation. Only one is open at a time.
import { icon } from './icons'
export interface MenuItem {
label?: string
action?: () => void
checked?: boolean // a check mark column is drawn when any item sets it
disabled?: boolean
hint?: string // right-aligned, like a shortcut
strong?: boolean
separator?: boolean
}
const menu = document.createElement('ul')
menu.className = 'menu popup'
menu.hidden = true
document.body.appendChild(menu)
let entries: { li: HTMLLIElement; item: MenuItem }[] = []
let index = -1
let anchor: Element | null = null
let onClose: (() => void) | null = null
export function menuOpen(): boolean {
return !menu.hidden
}
export function closeMenu() {
if (menu.hidden) return
menu.hidden = true
anchor = null
const done = onClose
onClose = null
done?.()
}
function highlight(i: number) {
index = i
entries.forEach((e, k) => e.li.classList.toggle('active', k === i))
}
function choose(item: MenuItem) {
closeMenu()
item.action?.()
}
/**
* Opens a menu with its top-left corner at (x, y), kept inside the window.
* A click on `from` (the button that opened it) is left to that button,
* so it can close the menu again.
*/
export function openMenu(items: MenuItem[], x: number, y: number, opts: { from?: Element; onClose?: () => void } = {}) {
closeMenu()
const checks = items.some((i) => i.checked !== undefined)
entries = []
menu.replaceChildren(
...items.map((item) => {
const li = document.createElement('li')
if (item.separator) {
li.className = 'sep'
return li
}
li.classList.toggle('disabled', !!item.disabled)
li.classList.toggle('strong', !!item.strong)
if (checks) {
const mark = document.createElement('span')
mark.className = 'check'
if (item.checked) mark.innerHTML = icon('check')
li.appendChild(mark)
}
li.append(item.label ?? '')
if (item.hint) {
const kbd = document.createElement('kbd')
kbd.textContent = item.hint
li.appendChild(kbd)
}
if (!item.disabled) {
const k = entries.length
entries.push({ li, item })
li.addEventListener('mousedown', (e) => {
e.preventDefault()
choose(item)
})
li.addEventListener('mousemove', () => highlight(k))
}
return li
}),
)
anchor = opts.from ?? null
onClose = opts.onClose ?? null
index = -1
menu.hidden = false
const rect = menu.getBoundingClientRect()
menu.style.left = `${Math.max(8, Math.min(x, innerWidth - rect.width - 8))}px`
menu.style.top = `${y + rect.height > innerHeight - 8 ? Math.max(8, y - rect.height) : y}px`
}
document.addEventListener('mousedown', (e) => {
const target = e.target as Node
if (!menu.hidden && !menu.contains(target) && !anchor?.contains(target)) closeMenu()
})
window.addEventListener('blur', closeMenu)
document.addEventListener('scroll', closeMenu, true)
window.addEventListener(
'keydown',
(e) => {
if (menu.hidden) return
const n = entries.length
const keys: Record<string, () => void> = {
ArrowDown: () => n && highlight((index + 1) % n),
ArrowUp: () => n && highlight((index - 1 + n) % n),
Enter: () => (index >= 0 ? choose(entries[index].item) : closeMenu()),
Escape: closeMenu,
}
if (!keys[e.key]) return
e.preventDefault()
e.stopPropagation()
keys[e.key]()
},
true,
)
+137
View File
@@ -0,0 +1,137 @@
// Production revisions: new text is marked with the current revision set
// while revisions are on, and locked pages keep their starting point as the
// text around them is edited. Pagination itself stays in Go.
import { EditorState, Plugin, Transaction } from 'prosemirror-state'
import { Mapping } from 'prosemirror-transform'
import { Node as PMNode } from 'prosemirror-model'
import { isHistoryTransaction } from 'prosemirror-history'
import type { PageBreak } from './backend'
import { blockText, flatElements, schema, utf16Offset, type LockAttr } from './schema'
/** Transactions made by these plugins, or that set locked pages themselves. */
const INTERNAL = 'plotzilla-revisions'
export interface CurrentRevision {
level: number
color: string
}
/** Every range a transaction inserted or replaced, in its final document. */
function changedRanges(trs: readonly Transaction[]): [number, number][] {
let ranges: [number, number][] = []
for (const tr of trs) {
ranges = ranges.map(([a, b]) => [tr.mapping.map(a, -1), tr.mapping.map(b, 1)])
if (!tr.docChanged || tr.getMeta(INTERNAL) || isHistoryTransaction(tr)) continue
tr.steps.forEach((step, i) => {
const rest = tr.mapping.slice(i + 1)
step.getMap().forEach((_oldStart, _oldEnd, start, end) => {
ranges.push([rest.map(start, -1), rest.map(end, 1)])
})
})
}
return ranges
}
/**
* Marks what is typed, pasted or replaced with the current revision set.
* A deletion marks the character next to it, so the line still gets its
* asterisk. Undo and redo are left alone.
*/
export function revisionPlugin(current: () => CurrentRevision | null): Plugin {
return new Plugin({
appendTransaction(trs, _old, state) {
const rev = current()
if (!rev || !trs.some((t) => t.docChanged)) return null
const mark = schema.marks.revision.create(rev)
const tr = state.tr
const size = state.doc.content.size
for (const [from, to] of changedRanges(trs)) {
if (to > from) {
tr.addMark(from, to, mark)
continue
}
const $pos = state.doc.resolve(Math.min(from, size))
if ($pos.parent.type !== schema.nodes.element) continue
if ($pos.parentOffset > 0) tr.addMark(from - 1, from, mark)
else if ($pos.parent.content.size > 0) tr.addMark(from, from + 1, mark)
}
return tr.docChanged ? tr.setMeta(INTERNAL, true) : null
},
})
}
interface Anchor {
pos: number
page: string
}
function anchors(doc: PMNode): Anchor[] {
const out: Anchor[] = []
for (const { node, pos } of flatElements(doc)) {
for (const l of (node.attrs.locked as LockAttr[] | null) ?? []) out.push({ pos: pos + 1 + l.offset, page: l.page })
}
return out
}
/** Sets the locked pages of every element; returns tr for chaining. */
function setLocks(tr: Transaction, perElement: Map<number, LockAttr[]>): Transaction {
for (const { node, pos } of flatElements(tr.doc)) {
const want = perElement.get(pos) ?? null
const have = node.attrs.locked as LockAttr[] | null
if (JSON.stringify(have) !== JSON.stringify(want)) tr.setNodeMarkup(pos, undefined, { ...node.attrs, locked: want })
}
return tr
}
/**
* Keeps each locked page where it started: its anchor follows the edits
* around it, into the next element when its own is deleted.
*/
export function lockPlugin(locked: () => boolean): Plugin {
return new Plugin({
appendTransaction(trs, oldState, state) {
if (!locked() || !trs.some((t) => t.docChanged) || trs.some((t) => t.getMeta(INTERNAL))) return null
const old = anchors(oldState.doc)
if (!old.length) return null
const mapping = new Mapping()
for (const tr of trs) mapping.appendMapping(tr.mapping)
const moved = old.map((a) => ({ pos: mapping.map(a.pos, -1), page: a.page })).sort((a, b) => a.pos - b.pos)
const flat = flatElements(state.doc)
const perElement = new Map<number, LockAttr[]>()
let e = 0
for (const a of moved) {
// The element holding the anchor, or the next one when it fell
// between elements.
while (e < flat.length - 1 && a.pos > flat[e].pos + 1 + flat[e].node.content.size) e++
const { node, pos } = flat[e]
const offset = Math.min(Math.max(a.pos - pos - 1, 0), node.content.size)
perElement.set(pos, [...(perElement.get(pos) ?? []), { offset, page: a.page }])
}
const tr = setLocks(state.tr, perElement)
return tr.docChanged ? tr.setMeta(INTERNAL, true) : null
},
})
}
/** Locks the pages where they break now. Not undoable, like the setting. */
export function lockPages(state: EditorState, breaks: PageBreak[]): Transaction {
const flat = flatElements(state.doc)
const perElement = new Map<number, LockAttr[]>()
for (const br of breaks) {
const entry = flat[br.element]
if (!entry) continue
const offset = utf16Offset(blockText(entry.node), br.offset)
perElement.set(entry.pos, [...(perElement.get(entry.pos) ?? []), { offset, page: br.label || String(br.page) }])
}
return setLocks(state.tr, perElement).setMeta(INTERNAL, true).setMeta('addToHistory', false)
}
export function unlockPages(state: EditorState): Transaction {
return setLocks(state.tr, new Map()).setMeta(INTERNAL, true).setMeta('addToHistory', false)
}
/** 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)
}
+299
View File
@@ -0,0 +1,299 @@
import { Schema, Node as PMNode, Fragment, Mark } from 'prosemirror-model'
import type { Element, ElementType, PageLock, Revision, Run, ScriptDocument, Settings, TitlePage } from './backend'
/** The first nine have the shortcuts Ctrl+1…9, in this order. */
export const ELEMENT_TYPES: ElementType[] = [
'scene',
'action',
'character',
'parenthetical',
'dialogue',
'transition',
'shot',
'intertitle',
'centered',
'section',
'synopsis',
]
/** Outline elements: part of the script file, never printed. */
export const isPrinted = (t: ElementType) => t !== 'section' && t !== 'synopsis'
export const LABELS: Record<ElementType, string> = {
scene: 'Scene Heading',
action: 'Action',
character: 'Character',
parenthetical: 'Parenthetical',
dialogue: 'Dialogue',
transition: 'Transition',
shot: 'Insert / Shot',
intertitle: 'Intertitle',
centered: 'Centered',
section: 'Section',
synopsis: 'Synopsis',
}
/** A locked page in the editor: its offset is in UTF-16 units (positions). */
export interface LockAttr {
offset: number
page: string
}
// One node type for every screenplay element: switching type is then a
// simple attribute change, which keeps Tab/Enter cycling cheap. A dual
// dialogue is a "dual" node holding two columns of elements.
export const schema = new Schema({
nodes: {
doc: { content: '(element | dual)+' },
element: {
content: 'inline*',
marks: '_',
defining: true,
// locked: LockAttr[] | null, the locked pages starting in the element.
attrs: {
type: { default: 'action' },
sceneNumber: { default: '' },
level: { default: 1 },
newPage: { default: false },
locked: { default: null },
},
parseDOM: [
{
tag: 'p',
// Locked pages are not copied: a page starts in one place only.
getAttrs: (dom) => {
const el = dom as HTMLElement
const type = el.dataset.type as ElementType | undefined
return {
type: type && ELEMENT_TYPES.includes(type) ? type : 'action',
sceneNumber: el.dataset.sceneNumber ?? '',
level: Math.max(1, Number(el.dataset.level) || 1),
newPage: el.dataset.newPage === 'true',
}
},
},
],
toDOM: (node) => [
'p',
{
class: `el el-${node.attrs.type}`,
'data-type': node.attrs.type,
...(node.attrs.sceneNumber ? { 'data-scene-number': node.attrs.sceneNumber } : {}),
...(node.attrs.type === 'section' ? { 'data-level': String(node.attrs.level) } : {}),
...(node.attrs.newPage ? { 'data-new-page': 'true' } : {}),
},
0,
],
},
dual: {
content: 'column column',
isolating: true,
parseDOM: [{ tag: 'div.dual' }],
toDOM: () => ['div', { class: 'dual' }, 0],
},
column: {
content: 'element+',
isolating: true,
parseDOM: [{ tag: 'div.dual-col' }],
toDOM: () => ['div', { class: 'dual-col' }, 0],
},
text: { group: 'inline' },
hard_break: {
inline: true,
group: 'inline',
selectable: false,
leafText: () => '\n',
parseDOM: [{ tag: 'br' }],
toDOM: () => ['br'],
},
},
marks: {
bold: { parseDOM: [{ tag: 'strong' }, { tag: 'b' }], toDOM: () => ['strong', 0] },
italic: { parseDOM: [{ tag: 'em' }, { tag: 'i' }], toDOM: () => ['em', 0] },
underline: { parseDOM: [{ tag: 'u' }], toDOM: () => ['u', 0] },
note: { parseDOM: [{ tag: 'span.note' }], toDOM: () => ['span', { class: 'note' }, 0] },
// Text of revision set `level`. Not inclusive: typing next to revised
// text is only marked while revisions are being marked.
revision: {
attrs: { level: { default: 1 }, color: { default: 'blue' } },
inclusive: false,
parseDOM: [
{
tag: 'span.rev',
getAttrs: (dom) => {
const el = dom as HTMLElement
return { level: Math.max(1, Number(el.dataset.level) || 1), color: el.dataset.color ?? 'blue' }
},
},
],
toDOM: (mark) => ['span', { class: 'rev', 'data-level': String(mark.attrs.level), 'data-color': mark.attrs.color }, 0],
},
},
})
export function elementType(node: PMNode): ElementType {
return node.attrs.type as ElementType
}
/** Plain text of an element, with hard breaks as "\n" (matches the Go side). */
export function blockText(node: PMNode): string {
return node.textBetween(0, node.content.size, '\n', '\n')
}
/** The printed text of an element: without its notes. */
export function printedText(node: PMNode): string {
let text = ''
node.forEach((child) => {
if (!schema.marks.note.isInSet(child.marks)) text += child.isText ? child.text! : '\n'
})
return text
}
/** Converts a rune offset (Go) into a UTF-16 offset (JS) within text. */
export function utf16Offset(text: string, runes: number): number {
let i = 0
for (const ch of text) {
if (runes-- <= 0) break
i += ch.length
}
return i
}
/** Converts a UTF-16 offset within text into a rune offset (Go). */
export function runeOffset(text: string, utf16: number): number {
return [...text.slice(0, utf16)].length
}
export interface FlatElement {
node: PMNode
pos: number
/** Position of the enclosing dual node, for elements of a dual dialogue. */
dualPos: number | null
/** Set on the first element of the right column. */
dual: boolean
}
/** Elements in document order, the same indexing as the Go side uses. */
export function flatElements(doc: PMNode): FlatElement[] {
const out: FlatElement[] = []
doc.forEach((node, offset) => {
if (node.type !== schema.nodes.dual) {
out.push({ node, pos: offset, dualPos: null, dual: false })
return
}
node.forEach((col, colOffset, colIndex) => {
col.forEach((el, elOffset, elIndex) => {
const pos = offset + 1 + colOffset + 1 + elOffset
out.push({ node: el, pos, dualPos: offset, dual: colIndex === 1 && elIndex === 0 })
})
})
})
return out
}
const sameStyle = (a: Run, b: Run) =>
!!a.b === !!b.b && !!a.i === !!b.i && !!a.u === !!b.u && !!a.n === !!b.n && (a.rev ?? 0) === (b.rev ?? 0)
function runsOf(node: PMNode): Run[] {
const runs: Run[] = []
node.forEach((child) => {
const run: Run = { text: child.isText ? child.text! : '\n' }
for (const m of child.marks) {
if (m.type === schema.marks.bold) run.b = true
if (m.type === schema.marks.italic) run.i = true
if (m.type === schema.marks.underline) run.u = true
if (m.type === schema.marks.note) run.n = true
if (m.type === schema.marks.revision) run.rev = m.attrs.level
}
const last = runs[runs.length - 1]
if (last && sameStyle(last, run)) {
last.text += run.text
} else {
runs.push(run)
}
})
return runs
}
export function toElement(node: PMNode): Element {
const el: Element = { type: elementType(node), runs: runsOf(node) }
if (node.attrs.sceneNumber) el.sceneNumber = node.attrs.sceneNumber
if (el.type === 'section') el.level = node.attrs.level
if (node.attrs.newPage) el.newPage = true
const locked = node.attrs.locked as LockAttr[] | null
if (locked?.length) {
const text = blockText(node)
el.locked = locked.map((l): PageLock => ({ offset: runeOffset(text, l.offset), page: l.page }))
}
return el
}
export function toScript(doc: PMNode, titlePage: TitlePage, settings: Settings): ScriptDocument {
const elements = flatElements(doc).map(({ node, dual }) => {
const el = toElement(node)
if (dual) el.dual = true
return el
})
return { titlePage, elements, settings }
}
/** revisions names the colour of revised text (Run.rev). */
export function fromElement(el: Element, revisions: Revision[] = []): PMNode {
const inline: PMNode[] = []
let text = ''
for (const run of el.runs ?? []) {
const marks: Mark[] = []
if (run.b) marks.push(schema.marks.bold.create())
if (run.i) marks.push(schema.marks.italic.create())
if (run.u) marks.push(schema.marks.underline.create())
if (run.n) marks.push(schema.marks.note.create())
if (run.rev) marks.push(schema.marks.revision.create({ level: run.rev, color: revisions[run.rev - 1]?.color ?? 'blue' }))
// A line break keeps the note or revision it belongs to, not emphasis.
const breakMarks = marks.filter((m) => m.type === schema.marks.note || m.type === schema.marks.revision)
run.text.split('\n').forEach((part, i) => {
if (i > 0) inline.push(schema.nodes.hard_break.create(null, null, breakMarks))
if (part) inline.push(schema.text(part, marks))
})
text += run.text
}
const type = ELEMENT_TYPES.includes(el.type) ? el.type : 'action'
const locked = el.locked?.length
? el.locked.map((l): LockAttr => ({ offset: utf16Offset(text, l.offset), page: l.page }))
: null
return schema.nodes.element.create(
{ type, sceneNumber: el.sceneNumber ?? '', level: Math.max(1, el.level ?? 1), newPage: !!el.newPage, locked },
inline,
)
}
export function dualNode(left: PMNode[], right: PMNode[]): PMNode {
return schema.nodes.dual.create(null, [
schema.nodes.column.create(null, left),
schema.nodes.column.create(null, right),
])
}
const inDialogue = (t: ElementType) => t === 'parenthetical' || t === 'dialogue'
export function fromScript(script: ScriptDocument): PMNode {
const els = script.elements ?? []
const revisions = script.settings?.revisions ?? []
const node = (el: Element) => fromElement(el, revisions)
const blocks: PMNode[] = []
let blockStart = -1 // index in blocks of the last top-level dialogue block
for (let i = 0; i < els.length; i++) {
const el = els[i]
if (el.type === 'character' && el.dual && blockStart >= 0) {
const right = [node(el)]
while (i + 1 < els.length && inDialogue(els[i + 1].type)) right.push(node(els[++i]))
blocks.push(dualNode(blocks.splice(blockStart), right))
blockStart = -1
continue
}
if (el.type === 'character') blockStart = blocks.length
else if (!inDialogue(el.type)) blockStart = -1
blocks.push(node(el))
}
if (blocks.length === 0) blocks.push(schema.nodes.element.create({ type: 'scene' }))
return schema.nodes.doc.create(null, Fragment.fromArray(blocks))
}
+145
View File
@@ -0,0 +1,145 @@
// Find and replace in the script body. The plugin keeps the query and its
// matches and highlights them; the commands move between matches by
// selecting them and replace through ordinary transactions, so undo works.
import { Command, EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { Node as PMNode } from 'prosemirror-model'
import { closeHistory } from 'prosemirror-history'
import { blockText, flatElements } from './schema'
import { replaceText } from './commands'
export interface SearchQuery {
text: string
caseSensitive: boolean
wholeWord: boolean
}
export interface Match {
from: number
to: number
}
interface SearchState {
query: SearchQuery
matches: Match[]
}
export const searchKey = new PluginKey<SearchState>('search')
const EMPTY: SearchQuery = { text: '', caseSensitive: false, wholeWord: false }
function pattern(query: SearchQuery): RegExp {
const escaped = query.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const source = query.wholeWord ? `(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])` : escaped
return new RegExp(source, query.caseSensitive ? 'gu' : 'giu')
}
function findMatches(doc: PMNode, query: SearchQuery): Match[] {
if (!query.text) return []
const re = pattern(query)
const matches: Match[] = []
for (const { node, pos } of flatElements(doc)) {
// blockText has one character per position (hard breaks included), so
// string indexes map directly onto document positions.
const text = blockText(node)
for (const m of text.matchAll(re)) {
matches.push({ from: pos + 1 + m.index, to: pos + 1 + m.index + m[0].length })
}
}
return matches
}
export function searchPlugin(): Plugin<SearchState> {
return new Plugin<SearchState>({
key: searchKey,
state: {
init: () => ({ query: EMPTY, matches: [] }),
apply(tr, prev, _old, state) {
const query = (tr.getMeta(searchKey) as SearchQuery | undefined) ?? prev.query
if (query === prev.query && !tr.docChanged) return prev
return { query, matches: findMatches(state.doc, query) }
},
},
props: {
decorations(state) {
const { matches } = searchKey.getState(state)!
if (!matches.length) return null
const { from, to } = state.selection
return DecorationSet.create(
state.doc,
matches.map((m) =>
Decoration.inline(m.from, m.to, { class: m.from === from && m.to === to ? 'search-match active' : 'search-match' }),
),
)
},
},
})
}
export function searchMatches(state: EditorState): Match[] {
return searchKey.getState(state)?.matches ?? []
}
/** Index of the match that is currently selected, or -1. */
export function activeMatch(state: EditorState): number {
const { from, to } = state.selection
return searchMatches(state).findIndex((m) => m.from === from && m.to === to)
}
export function setSearch(query: SearchQuery): Command {
return (state, dispatch) => {
if (dispatch) dispatch(state.tr.setMeta(searchKey, query).setMeta('addToHistory', false))
return true
}
}
/**
* Selects the next match after the selection (dir 1) or the previous one
* (dir -1), wrapping around the script. With `here`, a match starting at
* the cursor counts as the next one (incremental search while typing).
*/
export function selectMatch(dir: 1 | -1, here = false): Command {
return (state, dispatch) => {
const matches = searchMatches(state)
if (!matches.length) return false
const { from, to } = state.selection
const m =
dir > 0
? (matches.find((m) => m.from >= (here ? from : to)) ?? matches[0])
: (matches.findLast((m) => m.to <= from) ?? matches[matches.length - 1])
if (dispatch) dispatch(state.tr.setSelection(TextSelection.create(state.doc, m.from, m.to)).scrollIntoView())
return true
}
}
/** Replaces the selected match, then selects the next one. */
export function replaceMatch(text: string): Command {
return (state, dispatch) => {
const i = activeMatch(state)
if (i < 0) return selectMatch(1)(state, dispatch)
if (dispatch) {
const m = searchMatches(state)[i]
// Its own undo step, even right after typing or another replacement.
const tr = replaceText(closeHistory(state.tr), m.from, m.to, text)
const after = tr.mapping.map(m.to)
const next = findMatches(tr.doc, searchKey.getState(state)!.query).find((n) => n.from >= after)
if (next) tr.setSelection(TextSelection.create(tr.doc, next.from, next.to))
dispatch(tr.scrollIntoView())
}
return true
}
}
/** Replaces every match in one step, so a single undo restores them all. */
export function replaceAll(text: string): Command {
return (state, dispatch) => {
const matches = searchMatches(state)
if (!matches.length) return false
if (dispatch) {
const tr = closeHistory(state.tr)
for (const m of [...matches].reverse()) replaceText(tr, m.from, m.to, text)
dispatch(tr)
}
return true
}
}
+97
View File
@@ -0,0 +1,97 @@
// Spelling: wavy underlines under the words the Go checker reports. The
// word being typed is left alone until the cursor leaves it.
import { EditorState, Plugin, PluginKey, Transaction } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { Node as PMNode } from 'prosemirror-model'
import type { Misspelling } from './backend'
import { blockText, flatElements } from './schema'
import { utf16Offset } from './decorations'
interface SpellState {
set: DecorationSet
typing: boolean // the last change was an edit, not a cursor move
}
interface WordSpec {
word: string
}
export const spellKey = new PluginKey<SpellState>('spell')
export function buildSpellDecorations(doc: PMNode, found: Misspelling[], ignored: Set<string>): DecorationSet {
const flat = flatElements(doc)
const decos: Decoration[] = []
for (const m of found) {
const entry = flat[m.element]
if (!entry || ignored.has(m.word.toLowerCase())) continue
const text = blockText(entry.node)
const start = entry.pos + 1
const spec: WordSpec = { word: m.word }
decos.push(
Decoration.inline(
start + utf16Offset(text, m.offset),
start + utf16Offset(text, m.offset + m.length),
{ class: 'misspelled' },
spec,
),
)
}
return DecorationSet.create(doc, decos)
}
/** Maps the underlines through a change, dropping those it touched. */
function mapThrough(set: DecorationSet, tr: Transaction): DecorationSet {
let mapped = set.map(tr.mapping, tr.doc)
tr.mapping.maps.forEach((map, i) => {
const rest = tr.mapping.slice(i + 1)
map.forEach((_oldStart, _oldEnd, start, end) => {
const touched = mapped.find(rest.map(start, -1), rest.map(end, 1))
if (touched.length) mapped = mapped.remove(touched)
})
})
return mapped
}
export function spellPlugin(): Plugin<SpellState> {
return new Plugin<SpellState>({
key: spellKey,
state: {
init: () => ({ set: DecorationSet.empty, typing: false }),
apply(tr, prev) {
const fresh = tr.getMeta(spellKey) as DecorationSet | undefined
const typing = tr.docChanged ? true : tr.selectionSet ? false : prev.typing
if (fresh) return { set: fresh, typing }
if (tr.docChanged) return { set: mapThrough(prev.set, tr), typing }
return typing === prev.typing ? prev : { set: prev.set, typing }
},
},
props: {
decorations(state) {
const { set, typing } = spellKey.getState(state)!
if (!typing) return set
const head = state.selection.head
const here = set.find(head, head)
return here.length ? set.remove(here) : set
},
},
})
}
export interface MisspelledWord {
from: number
to: number
word: string
}
/** The underlined word at pos, if any. */
export function misspellingAt(state: EditorState, pos: number): MisspelledWord | null {
const d = spellKey.getState(state)?.set.find(pos, pos).find((d) => d.from <= pos && pos <= d.to)
return d ? { from: d.from, to: d.to, word: (d.spec as WordSpec).word } : null
}
/** The current underlines without those of word (added or ignored). */
export function withoutWord(state: EditorState, word: string): DecorationSet {
const set = spellKey.getState(state)!.set
const lower = word.toLowerCase()
return set.remove(set.find(undefined, undefined, (spec: WordSpec) => spec.word.toLowerCase() === lower))
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: { outDir: 'dist', emptyOutDir: true, target: 'es2020' },
})