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:
58
src/components/screenplay/PreviewPanel.tsx
Normal file
58
src/components/screenplay/PreviewPanel.tsx
Normal 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;
|
||||||
154
src/components/screenplay/ScreenplayEditor.tsx
Normal file
154
src/components/screenplay/ScreenplayEditor.tsx
Normal 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;
|
||||||
106
src/lib/screenplay/detect.test.ts
Normal file
106
src/lib/screenplay/detect.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/lib/screenplay/detect.ts
Normal file
91
src/lib/screenplay/detect.ts
Normal 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;
|
||||||
|
}
|
||||||
133
src/lib/screenplay/format.test.ts
Normal file
133
src/lib/screenplay/format.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
386
src/lib/screenplay/format.ts
Normal file
386
src/lib/screenplay/format.ts
Normal 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;
|
||||||
|
}
|
||||||
69
src/lib/screenplay/types.ts
Normal file
69
src/lib/screenplay/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user