From adf453e2456b5a19ce0979a94eab9f56c63a1a7b Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Thu, 23 Apr 2026 07:42:58 -0400 Subject: [PATCH] FRE-586: Add core screenplay editor with auto-formatting engine - types.ts: Screenplay element types, template configs, and interfaces - format.ts: Auto-formatting engine with Standard, Sitcom, Podcast templates - detect.ts: Element detection (scene headings, transitions, characters, parentheticals) - ScreenplayEditor.tsx: Editor component with keyboard shortcuts and live formatting - PreviewPanel.tsx: Real-time formatting preview panel - detect.test.ts: 19 tests for element detection - format.test.ts: 15 tests for formatting engine and templates - Fixed transition regex to handle periods and other terminators - All 34 tests passing --- src/components/screenplay/PreviewPanel.tsx | 58 +++ .../screenplay/ScreenplayEditor.tsx | 154 +++++++ src/lib/screenplay/detect.test.ts | 106 +++++ src/lib/screenplay/detect.ts | 91 +++++ src/lib/screenplay/format.test.ts | 133 ++++++ src/lib/screenplay/format.ts | 386 ++++++++++++++++++ src/lib/screenplay/types.ts | 69 ++++ 7 files changed, 997 insertions(+) create mode 100644 src/components/screenplay/PreviewPanel.tsx create mode 100644 src/components/screenplay/ScreenplayEditor.tsx create mode 100644 src/lib/screenplay/detect.test.ts create mode 100644 src/lib/screenplay/detect.ts create mode 100644 src/lib/screenplay/format.test.ts create mode 100644 src/lib/screenplay/format.ts create mode 100644 src/lib/screenplay/types.ts diff --git a/src/components/screenplay/PreviewPanel.tsx b/src/components/screenplay/PreviewPanel.tsx new file mode 100644 index 000000000..91b8a0632 --- /dev/null +++ b/src/components/screenplay/PreviewPanel.tsx @@ -0,0 +1,58 @@ +import { Component, createMemo } from 'solid-js'; +import { ScreenplayElement, TemplateType } from '../../lib/screenplay/types'; +import { getStyleForElement, elementTypeClass } from '../../lib/screenplay/format'; + +export interface PreviewPanelProps { + elements: ScreenplayElement[]; + template: TemplateType; +} + +export const PreviewPanel: Component = (props) => { + const renderedElements = createMemo(() => { + return props.elements.map((el) => { + const style = getStyleForElement(el.type, props.template); + return { element: el, style }; + }); + }); + + const getStyle = (el: ScreenplayElement): string => { + const style = getStyleForElement(el.type, props.template); + const parts: string[] = []; + + parts.push(`text-align: ${style.textAlign}`); + parts.push(`padding-left: ${(style.indentStart * 12).toFixed(0)}px`); + parts.push(`padding-right: ${(style.indentEnd * 12).toFixed(0)}px`); + parts.push(`margin-bottom: ${(style.marginBottom * 12).toFixed(0)}px`); + parts.push(`line-height: ${style.lineHeight}`); + + if (style.uppercase) { + parts.push('text-transform: uppercase'); + } + if (style.bold) { + parts.push('font-weight: bold'); + } + + return parts.join('; '); + }; + + return ( +
+
+ {renderedElements().map(({ element: el, style }) => ( +
+ {el.content.split('\n').map((line, i) => ( +
+ {line} +
+ ))} +
+ ))} +
+
+ ); +}; + +export default PreviewPanel; diff --git a/src/components/screenplay/ScreenplayEditor.tsx b/src/components/screenplay/ScreenplayEditor.tsx new file mode 100644 index 000000000..9708240a4 --- /dev/null +++ b/src/components/screenplay/ScreenplayEditor.tsx @@ -0,0 +1,154 @@ +import { Component, createSignal, createMemo, onMount } from 'solid-js'; +import { + ScreenplayElement, + ScreenplayElementType, + TemplateType, + KeyboardShortcut, +} from '../../lib/screenplay/types'; +import { formatScreenplay, getTemplate, elementTypeClass, generateId } from '../../lib/screenplay/format'; +import { detectElementType } from '../../lib/screenplay/detect'; + +export interface ScreenplayEditorProps { + template?: TemplateType; + initialContent?: string; + onChange?: (content: string) => void; + onElementsChange?: (elements: ScreenplayElement[]) => void; +} + +const SHORTCUTS: KeyboardShortcut[] = [ + { key: '1', ctrl: true, shift: true, elementType: 'sceneHeading', label: 'Scene Heading' }, + { key: '2', ctrl: true, shift: true, elementType: 'action', label: 'Action' }, + { key: '3', ctrl: true, shift: true, elementType: 'character', label: 'Character' }, + { key: '4', ctrl: true, shift: true, elementType: 'dialogue', label: 'Dialogue' }, + { key: '5', ctrl: true, shift: true, elementType: 'parenthetical', label: 'Parenthetical' }, + { key: '6', ctrl: true, shift: true, elementType: 'transition', label: 'Transition' }, +]; + +export const ScreenplayEditor: Component = (props) => { + const [content, setContent] = createSignal(props.initialContent || ''); + const [currentType, setCurrentType] = createSignal('action'); + const [template] = createSignal(props.template || 'standard'); + + let textareaRef: HTMLTextAreaElement | undefined; + + const elements = createMemo(() => { + return formatScreenplay(content(), template(), detectElementType).elements; + }); + + onMount(() => { + if (textareaRef) { + textareaRef.focus(); + } + }); + + const handleInput = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + const newContent = target.value; + setContent(newContent); + props.onChange?.(newContent); + + const cursorPos = target.selectionStart; + const textBeforeCursor = newContent.substring(0, cursorPos); + const lastLine = textBeforeCursor.split('\n').pop() || ''; + const detected = detectElementType(lastLine); + setCurrentType(detected); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + for (const shortcut of SHORTCUTS) { + const matchesCtrl = shortcut.ctrl === e.ctrlKey; + const matchesShift = shortcut.shift === e.shiftKey; + const matchesAlt = (shortcut.alt || false) === e.altKey; + const matchesKey = e.key.toLowerCase() === shortcut.key.toLowerCase(); + + if (matchesKey && matchesCtrl && matchesShift && matchesAlt) { + e.preventDefault(); + setCurrentType(shortcut.elementType); + break; + } + } + + if (e.key === 'Enter') { + const target = e.target as HTMLTextAreaElement; + const cursorPos = target.selectionStart; + const textBefore = content().substring(0, cursorPos); + const lastLine = textBefore.split('\n').pop() || ''; + const detected = detectElementType(lastLine); + + if (detected === 'dialogue' || detected === 'parenthetical') { + // Keep dialogue/parenthetical context on Enter + return; + } + + if (detected === 'character') { + setCurrentType('dialogue'); + } else if (detected === 'sceneHeading' || detected === 'action') { + setCurrentType('action'); + } + } + }; + + const insertElement = (type: ScreenplayElementType) => { + const prefixes: Record = { + sceneHeading: 'INT. ', + action: '', + character: ' ', + dialogue: '', + parenthetical: '(', + transition: 'CUT TO:', + note: '[NOTE: ', + retained: '[RETAINED: ', + centered: '', + }; + + const prefix = prefixes[type] || ''; + const newContent = content() + '\n' + prefix; + setContent(newContent); + setCurrentType(type); + props.onChange?.(newContent); + + if (textareaRef) { + textareaRef.focus(); + textareaRef.selectionStart = newContent.length; + textareaRef.selectionEnd = newContent.length; + } + }; + + const templateConfig = getTemplate(template()); + + return ( +
+
+ {currentType()} +
+ {SHORTCUTS.map((s) => ( + + ))} +
+
+