Created
August 24, 2008 21:53
-
-
Save mattmccray/7010 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// Graphic Novelist (graphic-novelist.js) | |
// Created by M@ McCray | |
// site: http://mattmccray.com | |
// email: matt at elucidata dot net | |
// | |
// API: | |
// | |
// GraphicNovelist.parse( text ) -> Parse text into array of script Nodes | |
// GraphicNovelist.renderNodes( nodes ) -> Renders array of script Nodes into HTML | |
// GraphicNovelist.render( text ) -> Parses and renders supplied text, returning HTML | |
// GraphicNovelist.renderTo( elementId, text ) -> Parses, renders, and replaces element's innerHTML with generated HTML | |
// Example script: | |
/* | |
@ Title: Our Hero | |
@ Author: Matt McCray | |
@ Revision: First Draft | |
[Page 1] | |
Panel 1 - Wide shot of The City. Our hero is patrolling the skies, ever | |
alert for any disturbances. | |
Panel 2 - Medium shot of the hero, getting a surprise message on his headset. | |
SFX: *crackle* *bzzz* | |
HERO: (confused) Yes? Who is this? | |
HEADSET: POTUS speaking. There's an urgent issue that requires your | |
unique, how shall we say? SKILLS. | |
Panel 3 - Close up on Hero. It seems that our hero is surprised to hear from | |
the President. But really, who wouldn't be? | |
HERO: Oh, uh, yes. I'd be happy to help out your, uh, majesty. | |
Er, highness? Uh... | |
HEADSET: Sir is fine. | |
HERO: Right, of course -- sir. | |
Panel 4 - Wide shot of our Hero pulling a 'U'-ey in the air, flying to the | |
President's secret office in Lincoln's skull at Mount Rushmore. | |
*/ | |
// CSS | |
/* | |
.graphic-novelist { | |
font-family: "Courier New", courier, monospace; | |
font-size: 13px; | |
width: 500px; | |
} | |
.graphic-novelist .title { | |
text-align: center; | |
} | |
.graphic-novelist .metadata { | |
margin: 0 auto; | |
font-size: 95%; | |
} | |
.graphic-novelist .metadata TH { | |
font-weight: normal; | |
text-align: right; | |
color: #777; | |
} | |
.graphic-novelist .panel .number { | |
font-weight: bold; | |
} | |
.graphic-novelist .dialog .character, .graphic-novelist .sfx .character { | |
width: 120px; | |
text-align: right; | |
float: left; | |
} | |
.graphic-novelist .dialog .parenthetical, .graphic-novelist .sfx .parenthetical { | |
font-style: italic; | |
font-size: 90%; | |
color: #444; | |
} | |
.graphic-novelist .dialog .count, .graphic-novelist .sfx .count { | |
text-decoration: none; | |
padding-top: 1px; | |
font-size: 85%; | |
color: #999; | |
font-weight: normal; | |
} | |
.graphic-novelist .dialog .body, .graphic-novelist .sfx .body { | |
margin-left: 125px; | |
padding-left: 0px; | |
} | |
@media print { | |
.graphic-novelist { | |
width: auto; | |
font-size: 12px; | |
} | |
.graphic-novelist .page { | |
page-break-before: always; | |
} | |
} | |
*/ | |
var GraphicNovelist = (function(){ | |
// HTML templates for rendering each node type | |
var TEMPLATES = { | |
title: '<h1 class="title"><%= body %></h1>', | |
metadata: '<table class="metadata"><% for(key in metadata){%><tr><th><%= key %>:</th><td><%= metadata[key] %></td></tr><%}%></table>', | |
page: '<h2 class="page"><%= body %></h2>', | |
panel: '<p class="panel"><span class="number"><%= metadata._panelPrefix %><%= panel %><%= metadata._panelSuffix %></span><%= body %>', | |
action: '<p class="action"><%= body %></p>', | |
dialog: '<dl class="<%= kind %>"><dt class="character"><span class="count"><%= count %> </span><%= character %><%= metadata._characterSuffix %></dt><dd class="body"><% if(parenthetical) {%><span class="parenthetical">(<%= parenthetical %>)</span> <%}%><%= body %></dd></dl>', | |
empty: ' ' | |
} | |
// SFX and Dialog are rendered the same way, by default. | |
TEMPLATES['sfx'] = TEMPLATES['dialog'] | |
// Regular expressions: | |
// Match text surrounded by whitespace | |
var TRIM_TEXT = /^[\s]*(.*?)[\s]*$/, | |
// Page headers: [Page #] | |
PAGE_HEAD = /^\[[\s]*(.*?)[\s]*\].*$/, | |
// Page numbers from page headers | |
PAGE_NUM = /(\d{1,})/, | |
// Meta keyword args: @NAME : VALUE | |
META_KWARG = /^@[\s]*(.*?)[\s]*:[\s]*(.*?)[\s]*$/, | |
// Dialog block: CHARACTER: DIALOG | |
DIALOG = /^[\s]*([^:]*?)[\s]*:[\s]*(.*)$/, | |
// Parenthetical: (PARENTHETICAL) DIALOG | |
PARENTHETICAL = /^.*?\([\s]*(.*?)[\s]*\)[\s]*(.*?)[\s]*$/, | |
// Panels: panel # - DESCRIPTION | |
PANEL = /^(?:p|panel)[\s]*([\d]{1,})[\s|\W]*(.*)[\s]*$/i; | |
// Node class | |
function Node(kind, prevNode, src, meta) { | |
// All the attributes that can/will be set... | |
this.previousNode = prevNode || null; | |
this.source = src || null; | |
this.kind = kind || 'empty'; | |
this.body = null; | |
this.character = null; | |
this.parenthetical = null; | |
this.panel = null; | |
this.metadata = meta; | |
} | |
// Simple JavaScript Templating | |
// John Resig - http://ejohn.org/ - MIT Licensed | |
var cache = {}; | |
function tmpl(str, data){ | |
// Figure out if we're getting a template, or if we need to | |
// load the template - and be sure to cache the result. | |
var fn = !/\W/.test(str) ? | |
cache[str] = cache[str] || tmpl( TEMPLATES[str] ) : // document.getElementById(str).innerHTML | |
// Generate a reusable function that will serve as a template | |
// generator (and which will be cached). | |
new Function("obj", | |
"var p=[],print=function(){p.push.apply(p,arguments);};" + | |
// Introduce the data as local variables using with(){} | |
"with(obj){p.push('" + | |
// Convert the template into pure JavaScript | |
str | |
.replace(/[\r\t\n]/g, " ") | |
.split("<%").join("\t") | |
.replace(/((^|%>)[^\t]*)'/g, "$1\r") | |
.replace(/\t=(.*?)%>/g, "',$1,'") | |
.split("\t").join("');") | |
.split("%>").join("p.push('") | |
.split("\r").join("\\'") | |
+ "');}return p.join('');"); | |
// Provide some basic currying to the user | |
return data ? fn( data ) : fn; | |
} | |
function trimWhitespace(text) { | |
return text.match(TRIM_TEXT)[1]; | |
} | |
function merge(from, to) { | |
for(prop in from) { | |
if(from.hasOwnProperty(prop)) { | |
to[prop] = from[prop]; | |
} | |
} | |
return to; | |
} | |
function parseLines(lines, opts) { | |
var lastNode = null, | |
lastLine = null, | |
pageCnt = 0, | |
dialogCnt = 1, | |
sfxCnt = 65, // ASCIII VALUE OF "A" | |
metaData = merge(opts, { | |
_panelSuffix: ' - ', | |
_panelPrefix: 'Panel ', | |
_characterSuffix: ':' | |
}), | |
errors = [], | |
nodes = []; | |
// Loop through each line of the script... | |
for (var i=0; i < lines.length; i++) { | |
var line = lines[i], | |
firstChar = line[0], | |
node = null; | |
// Decypher line type by the first character... | |
switch(firstChar) { | |
case '[': // Page Heading | |
node = new Node('page', lastNode, line, metaData); | |
node.body = line.match( PAGE_HEAD )[1] // Text within brackets [] | |
pageNumMatch = line.match( PAGE_NUM ) | |
pageCnt++; | |
node.number = (pageNumMatch) ? pageNumMatch[1] : pageCnt; | |
dialogCnt = 1; | |
sfxCnt = 65; // ASCII VALUE OF "A" | |
nodes.push(node); | |
lastNode = node; | |
break; | |
case '@': // Meta Data | |
var kwargs = line.match( META_KWARG ) | |
if(kwargs) { | |
metaData[ kwargs[1] ] = kwargs[2]; | |
if(kwargs[1] == '_characterList') { | |
metaData[ kwargs[2] ] = "..." | |
} | |
} | |
break; | |
case ' ': // Dialog or SFX | |
case "\t": | |
var dialogMatches = line.match( DIALOG ); | |
if( !dialogMatches ) { | |
if(lastNode && lastNode.kind == 'dialog') { | |
lastNode.body += " "+ trimWhitespace(line); | |
} | |
} else { | |
charName = dialogMatches[1]; | |
nodeType = (charName.toUpperCase() == 'SFX') ? 'sfx' : 'dialog'; | |
node = new Node(nodeType, lastNode, line, metaData); | |
node.character = charName; | |
dialogBodyMatches = dialogMatches[2].match( PARENTHETICAL ); | |
if(dialogBodyMatches) { | |
node.parenthetical = dialogBodyMatches[1]; | |
node.body = dialogBodyMatches[2]; | |
} else { | |
node.body = trimWhitespace(dialogMatches[2]); | |
} | |
if( node.kind == 'sfx') { | |
// SFX are 'numbered' with characters... | |
node.count = String.fromCharCode( (sfxCnt++) ); | |
} else { | |
node.count = (dialogCnt++); | |
} | |
nodes.push(node); | |
lastNode = node; | |
} | |
break; | |
default: // Action, Panel, or Empty line | |
if(typeof firstChar != 'undefined') { | |
var matches = line.match( PANEL ); | |
if(matches) { // Panel! | |
node = new Node('panel', lastNode, line, metaData); | |
node.panel = matches[1]; // Auto-number panels? | |
node.body = matches[2]; | |
nodes.push(node); | |
lastNode = node; | |
} else { | |
if( lastNode && (lastNode.kind == 'action' || lastNode.kind == 'panel') && (lastLine && lastLine != '') ) { | |
lastNode.body += ' '+ trimWhitespace(line); | |
} else { | |
node = new Node('action', lastNode, line, metaData); | |
node.body = trimWhitespace(line); | |
nodes.push(node); | |
lastNode = node; | |
} | |
} | |
} | |
} | |
lastLine = line; | |
}; | |
// Post processing... | |
if('_characterList' in metaData) { | |
var characters = [], | |
alreadyDef = {}; | |
for (var i=0; i < nodes.length; i++) { | |
var node = nodes[i]; | |
if(node.kind == 'dialog') { | |
if(!alreadyDef[node.character]) { | |
characters.push(node.character); | |
alreadyDef[node.character] = true; | |
} | |
} | |
}; | |
metaData[metaData['_characterList']] = characters.sort().join(", "); | |
} | |
// We need at least one node | |
if(nodes.length == 0) { | |
nodes.push(new Node('empty', null, '', metaData) ) | |
} | |
return nodes; | |
} | |
// Renders a node collection | |
function renderNodes(nodes, opts) { | |
var html = ['<div class="graphic-novelist">'], | |
firstNode = nodes[0], | |
safeMeta = {} | |
// Render all the 'safe' metadata | |
for(prop in firstNode.metadata) { | |
var isTitle = prop.match(/^(title)$/i) | |
if(isTitle) | |
html.push( tmpl('title', { body:firstNode.metadata[ isTitle[1] ] })) | |
else if(prop[0] != '_') | |
safeMeta[prop] = firstNode.metadata[prop]; | |
} | |
html.push(tmpl('metadata', {metadata:safeMeta})) | |
for (var i=0; i < nodes.length; i++) { | |
var node = nodes[i]; | |
html.push( tmpl(node.kind, node) ) | |
}; | |
html.push("</div>") | |
return html.join(''); | |
} | |
// The public API | |
return { | |
// Parse text into array of script Nodes | |
parse: function(text, opts) { | |
var lines = text.split("\n"); | |
var nodes = parseLines(lines, opts); | |
return nodes; | |
}, | |
// Renders array of script Nodes into HTML | |
renderNodes: function(nodes, opts) { | |
return renderNodes(nodes, opts); | |
}, | |
// Parses and renders supplied text, returning HTML | |
render: function(text, opts) { | |
// Loop over nodes and generate appropriate HTML... | |
return GraphicNovelist.renderNodes(GraphicNovelist.parse(text, opts), opts); | |
}, | |
// Parses, renders, and replaces element's innerHTML with generated HTML | |
renderTo: function(elemId, text, opts) { | |
document.getElementById(elemId).innerHTML = GraphicNovelist.render(text, opts); | |
} | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment