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

+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 })
},
})
}