feat: first release !
This commit is contained in:
1 parent
77a333e8d9
commit
664527182d
66 files changed
+13812
-16
No files matched your search
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)],
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in new issue
Block a user