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
This commit is contained in:
Senior Engineer
2026-04-23 07:42:58 -04:00
committed by Michael Freno
parent 1c74a082e5
commit adf453e245
7 changed files with 997 additions and 0 deletions

View File

@@ -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<PreviewPanelProps> = (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 (
<div class="screenplay-preview">
<div class="screenplay-page">
{renderedElements().map(({ element: el, style }) => (
<div
class={elementTypeClass(el.type)}
style={getStyle(el)}
>
{el.content.split('\n').map((line, i) => (
<div class="preview-line" style={i === el.content.split('\n').length - 1 && line.trim() === '' ? 'display: none' : ''}>
{line}
</div>
))}
</div>
))}
</div>
</div>
);
};
export default PreviewPanel;

View File

@@ -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<ScreenplayEditorProps> = (props) => {
const [content, setContent] = createSignal(props.initialContent || '');
const [currentType, setCurrentType] = createSignal<ScreenplayElementType>('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<ScreenplayElementType, string> = {
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 (
<div class="screenplay-editor-container">
<div class="screenplay-toolbar">
<span class="current-type">{currentType()}</span>
<div class="shortcuts">
{SHORTCUTS.map((s) => (
<button
class="shortcut-btn"
onClick={() => insertElement(s.elementType)}
title={`${s.label} (Ctrl+Shift+${s.key.toUpperCase()})`}
>
{s.label}
</button>
))}
</div>
</div>
<textarea
ref={textareaRef}
value={content()}
onInput={handleInput}
onKeyDown={handleKeyDown}
class={`screenplay-textarea ${elementTypeClass(currentType())}`}
style={{
'font-family': templateConfig.fontFamily,
'font-size': `${templateConfig.fontSize}px`,
'line-height': '1.5',
'white-space': 'pre-wrap',
'tab-size': '4',
}}
placeholder="Start writing your screenplay..."
/>
</div>
);
};
export default ScreenplayEditor;

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { detectElementType, detectWithType, isSceneHeading, isTransition, isCharacter, isParenthetical, detectNamedEntities } from './detect';
import { ScreenplayElementType } from './types';
describe('detectElementType', () => {
it('detects INT scene headings', () => {
expect(detectElementType('INT. LIVING ROOM - DAY')).toBe('sceneHeading');
});
it('detects EXT scene headings', () => {
expect(detectElementType('EXT. PARK - NIGHT')).toBe('sceneHeading');
});
it('detects INT/EXT scene headings', () => {
expect(detectElementType('INT/EXT. BUILDING - DAY')).toBe('sceneHeading');
});
it('detects transitions', () => {
expect(detectElementType('CUT TO:')).toBe('transition');
expect(detectElementType('FADE IN:')).toBe('transition');
expect(detectElementType('FADE OUT.')).toBe('transition');
expect(detectElementType('DISSOLVE TO:')).toBe('transition');
});
it('detects parentheticals', () => {
expect(detectElementType('(whispering)')).toBe('parenthetical');
expect(detectElementType('(V.O.)')).toBe('parenthetical');
});
it('detects character names with indentation', () => {
expect(detectElementType(' JOHN')).toBe('character');
expect(detectElementType(' MARY')).toBe('character');
});
it('detects all-caps character names without spaces', () => {
expect(detectElementType('JOHN')).toBe('character');
expect(detectElementType('MARY-ANN')).toBe('character');
});
it('defaults to dialogue for normal text', () => {
expect(detectElementType('Hello, how are you?')).toBe('dialogue');
});
it('treats empty lines as action', () => {
expect(detectElementType('')).toBe('action');
expect(detectElementType(' ')).toBe('action');
});
});
describe('detectWithType', () => {
it('returns high confidence for scene headings', () => {
const result = detectWithType('INT. LIVING ROOM - DAY');
expect(result.type).toBe('sceneHeading');
expect(result.confidence).toBeCloseTo(0.95);
});
it('returns high confidence for transitions', () => {
const result = detectWithType('CUT TO:');
expect(result.type).toBe('transition');
expect(result.confidence).toBeCloseTo(0.95);
});
it('returns medium confidence for ambiguous text', () => {
const result = detectWithType('Hello world');
expect(result.confidence).toBe(0.5);
});
});
describe('helper functions', () => {
it('isSceneHeading returns true for scene headings', () => {
expect(isSceneHeading('INT. ROOM - DAY')).toBe(true);
expect(isSceneHeading('HELLO')).toBe(false);
});
it('isTransition returns true for transitions', () => {
expect(isTransition('FADE IN:')).toBe(true);
expect(isTransition('HELLO')).toBe(false);
});
it('isCharacter returns true for character names', () => {
expect(isCharacter(' JOHN')).toBe(true);
expect(isCharacter('hello')).toBe(false);
});
it('isParenthetical returns true for parentheticals', () => {
expect(isParenthetical('(smiling)')).toBe(true);
expect(isParenthetical('hello')).toBe(false);
});
});
describe('detectNamedEntities', () => {
it('extracts character names from indented lines', () => {
const names = detectNamedEntities(' JOHN DOE');
expect(names).toContain('JOHN DOE');
});
it('extracts location from scene headings', () => {
const names = detectNamedEntities('INT. LIVING ROOM - DAY');
expect(names).toContain('LIVING ROOM - DAY');
});
it('returns empty array for regular text', () => {
const names = detectNamedEntities('Hello world');
expect(names).toEqual([]);
});
});

View File

@@ -0,0 +1,91 @@
import { ScreenplayElementType, DetectionResult } from './types';
const SCENE_HEADING_RE = /^(INT|EXT|INT\/EXT|I\/E)\.\s*/i;
const TRANSITION_RE = /^(CUT TO|FADE IN|FADE OUT|SMASH CUT|MATCH CUT|DISSOLVE TO|IRIS IN|IRIS OUT|BACK TO|TIME CUT|JUMP CUT)\s*[:.!?]?\s*$/i;
const PARENTHETICAL_RE = /^\(.*\)$/;
const CHARACTER_RE = /^\s{4,}([A-Z][A-Z0-9.'-]+(?:\s+[A-Z][A-Z0-9.'-]+)*)\s*$/;
const EMPTY_LINE_RE = /^\s*$/;
export function detectElementType(line: string): ScreenplayElementType {
const trimmed = line.trim();
if (EMPTY_LINE_RE.test(trimmed)) {
return 'action';
}
if (SCENE_HEADING_RE.test(trimmed)) {
return 'sceneHeading';
}
if (TRANSITION_RE.test(trimmed)) {
return 'transition';
}
if (PARENTHETICAL_RE.test(trimmed)) {
return 'parenthetical';
}
if (CHARACTER_RE.test(line)) {
return 'character';
}
if (trimmed === trimmed.toUpperCase() && trimmed.length < 60 && !trimmed.includes(' ')) {
return 'character';
}
return 'dialogue';
}
export function detectWithType(line: string): DetectionResult {
const type = detectElementType(line);
let confidence = 0.5;
if (SCENE_HEADING_RE.test(line.trim())) {
confidence = 0.95;
} else if (TRANSITION_RE.test(line.trim())) {
confidence = 0.95;
} else if (PARENTHETICAL_RE.test(line.trim())) {
confidence = 0.85;
} else if (CHARACTER_RE.test(line)) {
confidence = 0.9;
} else if (line.trim() === line.trim().toUpperCase() && line.trim().length < 60) {
confidence = 0.6;
}
return { type, confidence, text: line };
}
export function isSceneHeading(line: string): boolean {
return detectElementType(line) === 'sceneHeading';
}
export function isTransition(line: string): boolean {
return detectElementType(line) === 'transition';
}
export function isCharacter(line: string): boolean {
return detectElementType(line) === 'character';
}
export function isParenthetical(line: string): boolean {
return detectElementType(line) === 'parenthetical';
}
export function detectNamedEntities(line: string): string[] {
const trimmed = line.trim();
const names: string[] = [];
if (CHARACTER_RE.test(line)) {
const match = line.match(/([A-Z][A-Z0-9.'-]+(?:\s+[A-Z][A-Z0-9.'-]+)*)/);
if (match && match[1]) {
names.push(match[1]);
}
}
const sceneMatch = trimmed.match(/^(?:INT|EXT|INT\/EXT|I\/E)\.\s+(.*)/i);
if (sceneMatch && sceneMatch[1]) {
names.push(sceneMatch[1].trim());
}
return names;
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { formatScreenplay, getTemplate, getAllTemplates, elementTypeClass, formatLineForPreview, generateId } from './format';
import { detectElementType } from './detect';
import { ScreenplayElementType, TemplateType } from './types';
describe('generateId', () => {
it('generates unique IDs', () => {
const id1 = generateId();
const id2 = generateId();
expect(id1).not.toBe(id2);
expect(id1.startsWith('el-')).toBe(true);
});
});
describe('getTemplate', () => {
it('returns standard template by default', () => {
const template = getTemplate('standard');
expect(template.name).toBe('standard');
expect(template.fontFamily).toContain('Courier');
});
it('returns sitcom template', () => {
const template = getTemplate('sitcom');
expect(template.name).toBe('sitcom');
expect(template.elementStyles.character.indentStart).toBe(2.0);
});
it('returns podcast template', () => {
const template = getTemplate('podcast');
expect(template.name).toBe('podcast');
expect(template.elementStyles.character.bold).toBe(true);
});
it('getAllTemplates returns all three', () => {
const templates = getAllTemplates();
expect(templates).toHaveLength(3);
expect(templates.map((t) => t.name)).toEqual(['standard', 'sitcom', 'podcast']);
});
});
describe('elementTypeClass', () => {
it('returns correct CSS class for each type', () => {
expect(elementTypeClass('sceneHeading')).toBe('screenplay-sceneHeading');
expect(elementTypeClass('dialogue')).toBe('screenplay-dialogue');
expect(elementTypeClass('transition')).toBe('screenplay-transition');
});
});
describe('formatScreenplay', () => {
it('formats a simple screenplay', () => {
const raw = `INT. LIVING ROOM - DAY
A cozy living room with a fireplace.
JOHN
Hello, world.
MARY
(smiling)
Hi there.`;
const result = formatScreenplay(raw, 'standard', detectElementType);
expect(result.elements.length).toBeGreaterThan(0);
expect(result.template).toBe('standard');
const sceneHeading = result.elements.find((e) => e.type === 'sceneHeading');
expect(sceneHeading).toBeDefined();
expect(sceneHeading?.content).toContain('INT.');
});
it('handles empty input', () => {
const result = formatScreenplay('', 'standard', detectElementType);
expect(result.elements).toEqual([]);
});
it('groups consecutive same-type lines', () => {
const raw = ` JOHN
Line one.
Line two.`;
const result = formatScreenplay(raw, 'standard', detectElementType);
const dialogueElements = result.elements.filter((e) => e.type === 'dialogue');
expect(dialogueElements.length).toBe(1);
const firstDialogue = dialogueElements[0];
if (firstDialogue) {
expect(firstDialogue.content).toContain('Line one');
expect(firstDialogue.content).toContain('Line two');
}
});
});
describe('formatLineForPreview', () => {
it('applies uppercase for scene headings', () => {
const formatted = formatLineForPreview('int. room', 'sceneHeading', 'standard');
expect(formatted).toContain('INT.');
});
it('adds indentation based on template', () => {
const formatted = formatLineForPreview('JOHN', 'character', 'standard');
expect(formatted.startsWith(' ')).toBe(true);
});
});
describe('template element styles', () => {
it('standard template has correct scene heading style', () => {
const template = getTemplate('standard');
const style = template.elementStyles.sceneHeading;
expect(style.uppercase).toBe(true);
expect(style.bold).toBe(true);
expect(style.indentStart).toBe(0);
});
it('standard template has correct character style', () => {
const template = getTemplate('standard');
const style = template.elementStyles.character;
expect(style.indentStart).toBe(3.7);
expect(style.uppercase).toBe(true);
});
it('standard template has correct dialogue style', () => {
const template = getTemplate('standard');
const style = template.elementStyles.dialogue;
expect(style.indentStart).toBe(2.5);
expect(style.indentEnd).toBe(2.5);
});
it('standard template has correct transition style', () => {
const template = getTemplate('standard');
const style = template.elementStyles.transition;
expect(style.textAlign).toBe('right');
expect(style.uppercase).toBe(true);
});
});

View File

@@ -0,0 +1,386 @@
/**
* Auto-formatting engine for screenplay elements
* Final Draft compatible formatting with template support
*/
import {
TemplateConfig,
ScreenplayElementType,
ElementStyle,
ScreenplayElement,
FormatResult,
TemplateType,
} from './types';
let idCounter = 0;
export function generateId(): string {
return `el-${Date.now()}-${++idCounter}`;
}
const STANDARD_TEMPLATE: TemplateConfig = {
name: 'standard',
label: 'Standard Screenplay',
pageWidth: 8.5,
pageHeight: 11,
topMargin: 1.0,
bottomMargin: 1.0,
leftMargin: 1.0,
rightMargin: 1.0,
fontSize: 12,
fontFamily: '"Courier New", Courier, monospace',
elementStyles: {
sceneHeading: {
textAlign: 'left',
uppercase: true,
bold: true,
indentStart: 0,
indentEnd: 0,
lineHeight: 2,
marginBottom: 1,
},
action: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 1,
},
character: {
textAlign: 'left',
uppercase: true,
bold: false,
indentStart: 3.7,
indentEnd: 0,
lineHeight: 1,
marginBottom: 0,
},
dialogue: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 2.5,
indentEnd: 2.5,
lineHeight: 1,
marginBottom: 0,
},
parenthetical: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 3.1,
indentEnd: 3.5,
lineHeight: 1,
marginBottom: 0,
},
transition: {
textAlign: 'right',
uppercase: true,
bold: false,
indentStart: 0,
indentEnd: 1.5,
lineHeight: 1,
marginBottom: 1,
},
note: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 1.0,
indentEnd: 1.0,
lineHeight: 1,
marginBottom: 0.5,
},
retained: {
textAlign: 'left',
uppercase: false,
bold: true,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 0,
},
centered: {
textAlign: 'center',
uppercase: false,
bold: false,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 1,
},
},
};
const SITCOM_TEMPLATE: TemplateConfig = {
name: 'sitcom',
label: 'Sitcom / Multi-Camera',
pageWidth: 8.5,
pageHeight: 11,
topMargin: 1.0,
bottomMargin: 1.0,
leftMargin: 1.0,
rightMargin: 1.0,
fontSize: 12,
fontFamily: '"Courier New", Courier, monospace',
elementStyles: {
sceneHeading: {
textAlign: 'left',
uppercase: true,
bold: true,
indentStart: 0,
indentEnd: 0,
lineHeight: 2,
marginBottom: 1,
},
action: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 1,
},
character: {
textAlign: 'left',
uppercase: true,
bold: false,
indentStart: 2.0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 0,
},
dialogue: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 2.5,
indentEnd: 2.5,
lineHeight: 1,
marginBottom: 0,
},
parenthetical: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 3.1,
indentEnd: 3.5,
lineHeight: 1,
marginBottom: 0,
},
transition: {
textAlign: 'right',
uppercase: true,
bold: false,
indentStart: 0,
indentEnd: 1.5,
lineHeight: 1,
marginBottom: 1,
},
note: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 1.0,
indentEnd: 1.0,
lineHeight: 1,
marginBottom: 0.5,
},
retained: {
textAlign: 'left',
uppercase: false,
bold: true,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 0,
},
centered: {
textAlign: 'center',
uppercase: false,
bold: false,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 1,
},
},
};
const PODCAST_TEMPLATE: TemplateConfig = {
name: 'podcast',
label: 'Podcast Script',
pageWidth: 8.5,
pageHeight: 11,
topMargin: 1.0,
bottomMargin: 1.0,
leftMargin: 1.0,
rightMargin: 1.0,
fontSize: 12,
fontFamily: '"Courier New", Courier, monospace',
elementStyles: {
sceneHeading: {
textAlign: 'left',
uppercase: true,
bold: true,
indentStart: 0,
indentEnd: 0,
lineHeight: 2,
marginBottom: 1,
},
action: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 1,
},
character: {
textAlign: 'left',
uppercase: true,
bold: true,
indentStart: 1.5,
indentEnd: 0,
lineHeight: 1,
marginBottom: 0,
},
dialogue: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 2.0,
indentEnd: 2.0,
lineHeight: 1,
marginBottom: 0.5,
},
parenthetical: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 2.5,
indentEnd: 3.0,
lineHeight: 1,
marginBottom: 0,
},
transition: {
textAlign: 'right',
uppercase: true,
bold: false,
indentStart: 0,
indentEnd: 1.5,
lineHeight: 1,
marginBottom: 1,
},
note: {
textAlign: 'left',
uppercase: false,
bold: false,
indentStart: 1.0,
indentEnd: 1.0,
lineHeight: 1,
marginBottom: 0.5,
},
retained: {
textAlign: 'left',
uppercase: false,
bold: true,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 0,
},
centered: {
textAlign: 'center',
uppercase: false,
bold: false,
indentStart: 0,
indentEnd: 0,
lineHeight: 1,
marginBottom: 1,
},
},
};
const TEMPLATES: Record<TemplateType, TemplateConfig> = {
standard: STANDARD_TEMPLATE,
sitcom: SITCOM_TEMPLATE,
podcast: PODCAST_TEMPLATE,
};
export function getTemplate(name: TemplateType): TemplateConfig {
return TEMPLATES[name];
}
export function getAllTemplates(): TemplateConfig[] {
return Object.values(TEMPLATES);
}
export function elementTypeClass(type: ScreenplayElementType): string {
return `screenplay-${type}`;
}
export function formatScreenplay(
rawText: string,
templateType: TemplateType = 'standard',
detectElementType: (line: string) => ScreenplayElementType
): FormatResult {
const lines = rawText.split('\n');
const elements: ScreenplayElement[] = [];
let currentType: ScreenplayElementType = 'action';
let currentContent = '';
const flushElement = () => {
if (currentContent.trim()) {
elements.push({
id: generateId(),
type: currentType,
content: currentContent.trimEnd(),
});
}
currentContent = '';
};
for (const line of lines) {
const detected = detectElementType(line);
if (detected !== currentType) {
flushElement();
currentType = detected;
}
currentContent += line + '\n';
}
flushElement();
return { elements, template: templateType };
}
export function getStyleForElement(
type: ScreenplayElementType,
template: TemplateType
): ElementStyle {
return getTemplate(template).elementStyles[type];
}
export function formatLineForPreview(
content: string,
type: ScreenplayElementType,
template: TemplateType
): string {
const style = getStyleForElement(type, template);
let formatted = content;
if (style.uppercase) {
formatted = formatted.toUpperCase();
}
const indent = ' '.repeat(Math.round(style.indentStart * 2));
formatted = indent + formatted;
return formatted;
}

View File

@@ -0,0 +1,69 @@
/**
* Screenplay element types and interfaces
* Industry-standard formatting (Final Draft compatible)
*/
export type ScreenplayElementType =
| 'sceneHeading'
| 'action'
| 'character'
| 'dialogue'
| 'parenthetical'
| 'transition'
| 'note'
| 'retained'
| 'centered';
export type TemplateType = 'standard' | 'sitcom' | 'podcast';
export interface ScreenplayElement {
id: string;
type: ScreenplayElementType;
content: string;
page?: number;
line?: number;
}
export interface TemplateConfig {
name: TemplateType;
label: string;
pageWidth: number;
pageHeight: number;
topMargin: number;
bottomMargin: number;
leftMargin: number;
rightMargin: number;
fontSize: number;
fontFamily: string;
elementStyles: Record<ScreenplayElementType, ElementStyle>;
}
export interface ElementStyle {
textAlign: 'left' | 'center' | 'right';
uppercase: boolean;
bold: boolean;
indentStart: number;
indentEnd: number;
lineHeight: number;
marginBottom: number;
}
export interface FormatResult {
elements: ScreenplayElement[];
template: TemplateType;
}
export interface DetectionResult {
type: ScreenplayElementType;
confidence: number;
text: string;
}
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
elementType: ScreenplayElementType;
label: string;
}