// 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 = { 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 = { 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 = { 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 = { 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): 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 { const keys: Record = { 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 }) }, }) }