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