diff --git a/.gitignore b/.gitignore index d19d857..97ed884 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ package-lock.json # Build outputs /*.map -/*.css script.js +script.css +fonts/ coverage dist build diff --git a/index.html b/index.html index f5e8cd9..add8421 100644 --- a/index.html +++ b/index.html @@ -4,15 +4,8 @@ Screenwriter - - - - - - - - - + +
diff --git a/package.json b/package.json index a3b8b06..850eb71 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A simple copy of Final Draft that can be used online", "main": "app.js", "dependencies": { - "bootstrap": "^5.3.8", + "bootstrap": "^3.4.1", "firebase": "^12.9.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -13,7 +13,6 @@ "underscore": "^1.13.7" }, "devDependencies": { - "@babel/cli": "^7.28.6", "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.0", "@babel/preset-react": "^7.28.5", @@ -21,6 +20,8 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "babel-jest": "^30.2.0", + "esbuild": "^0.27.4", + "esbuild-sass-plugin": "^3.7.0", "gulp": "^5.0.1", "gulp-plumber": "^1.2.1", "gulp-sass": "^6.0.1", @@ -30,9 +31,9 @@ "sass": "^1.97.3" }, "scripts": { - "build": "npm run build:jsx && npm run build:css", - "build:jsx": "babel script.jsx --out-file script.js --presets=@babel/preset-react --no-babelrc", - "build:css": "gulp sass", + "build": "node scripts/build.js", + "build:jsx": "node scripts/build.js", + "build:css": "node scripts/build.js", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" diff --git a/script.jsx b/script.jsx index 556558d..e86f201 100644 --- a/script.jsx +++ b/script.jsx @@ -1,657 +1,10 @@ -var types = ['scene', 'action', 'character', 'dialogue', 'parenthetical', 'transition', 'shot', 'text']; -var nextTypes = { - scene: 'action', - action: 'action', - character: 'dialogue', - dialogue: 'character', - parenthetical: 'dialogue', - transition: 'scene', - shot: 'action', - text: 'text' -}; +// CSS entry – bundled by esbuild so Bootstrap and app styles are self-hosted +import 'bootstrap/dist/css/bootstrap.min.css'; +import './styles.scss'; +import './print.scss'; -var StopPropagationMixin = { - stopProp: function(event) { - event.nativeEvent.stopImmediatePropagation(); - }, -}; -function cursorPos(element) { - var caretOffset = 0; - var doc = element.ownerDocument || element.document; - var win = doc.defaultView || doc.parentWindow; - var sel; - if (typeof win.getSelection != "undefined") { - sel = win.getSelection(); - if (sel.rangeCount > 0) { - var range = win.getSelection().getRangeAt(0); - var preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretOffset = preCaretRange.toString().length; - } - } else if ( (sel = doc.selection) && sel.type != "Control") { - var textRange = sel.createRange(); - var preCaretTextRange = doc.body.createTextRange(); - preCaretTextRange.moveToElementText(element); - preCaretTextRange.setEndPoint("EndToEnd", textRange); - caretOffset = preCaretTextRange.text.length; - } - return caretOffset; -}; +import { createRoot } from 'react-dom/client'; +import App from './src/App.jsx'; -function placeCaretAtEnd(el) { - el.focus(); - if (typeof window.getSelection != "undefined" - && typeof document.createRange != "undefined") { - var range = document.createRange(); - range.selectNodeContents(el); - range.collapse(false); - var sel = window.getSelection(); - sel.removeAllRanges(); - sel.addRange(range); - } else if (typeof document.body.createTextRange != "undefined") { - var textRange = document.body.createTextRange(); - textRange.moveToElementText(el); - textRange.collapse(false); - textRange.select(); - } -} - -function S4() { - return (((1+Math.random())*0x10000)|0).toString(16).substring(1); -} -function guid() { - return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4()); -} - - -var Script = React.createClass({ - mixins: [ReactFireMixin, ReactRouter.State], - getInitialState: function() { - highlight = ''; - - return { - scriptId: this.getParams().scriptId, - action: this.getParams().action, - script: {}, - editing: {} - }; - }, - componentWillMount: function() { - this.loadScript(); - }, - componentWillReceiveProps: function() { - this.loadScript(); - }, - loadScript: function() { - if (this.firebaseRefs.script) this.unbind('script'); - this.bindAsObject(new Firebase("https://screenwrite.firebaseio.com/"+this.getParams().scriptId), "script"); - // CLEANUP OLD DATA - var fb = new Firebase("https://screenwrite.firebaseio.com/"+this.state.scriptId); - fb.once('value', (function(snapshot){ - if (!snapshot.val()) { - fb.set({}); - var newLine = fb.child('lines').push({ type: 'scene' }); - fb.update({ firstLine: newLine.key }); - return; - } - if (snapshot.val().firstLine) return; - var previous, previousIndex; - fb.update({firstLine: '0'}); - _.each(snapshot.val().lines, function(line, index) { - if (previous) { - fb.child('lines/'+previousIndex+'/next').set(index); - } - previous = line; - previousIndex = index; - }); - }).bind(this)); - - window.onunload = (function(){ - if (_.keys(this.state.script.lines).length <= 2) - fb.remove(); - }).bind(this); - }, - editing: function(line) { - this.setState({editing:line}); - }, - getSuggestion: function(lineIndex, fromValue) { - if (!this.state.script.lines[lineIndex].text) return ''; - var type = this.state.script.lines[lineIndex].type; - var text = fromValue && fromValue.toUpperCase() || this.state.script.lines[lineIndex].text.toUpperCase(); - - var suggestions = []; - var passed = false; - var iterate = (function(index){ - var line = this.state.script.lines[index]; - if (!line) { - console.warn('Line not found at index:', index); - return; - } - if (line.type == type - && line.text - && line.text.length > text.length - && line.text.toUpperCase().indexOf(text) === 0) - suggestions.push(line.text.toUpperCase()); - if (index == lineIndex) - passed = true; - if (passed && suggestions.length) return; - if (line.next && this.state.script.lines[line.next]) - iterate(line.next); - }).bind(this); - iterate(this.state.script.firstLine); - return (suggestions.pop() || '').substr(text.length); - }, - handleKey: function(event, line, index, prevIndex, prevPrevIndex) { - switch (event.keyCode) { - case 38: // up - if (prevIndex) { - if (event.metaKey || event.ctrlKey) { - // [a, b, C, d] => [a, C, b, d] - // A points to C - if (prevPrevIndex) - this.firebaseRefs.script.child('lines/'+prevPrevIndex).update({next: index}); - else - this.firebaseRefs.script.update({firstLine:index}); - // C points to B - var newNext = line.next; - this.firebaseRefs.script.child('lines/'+index).update({next: prevIndex }); - // B points to D - if (line.next) - this.firebaseRefs.script.child('lines/'+prevIndex).update({next: newNext }); - else - this.firebaseRefs.script.child('lines/'+prevIndex+'/next').remove(); - this.refs['line'+index].focus(true); - event.preventDefault(); - } else if (!cursorPos(event.target)) { - this.refs['line'+prevIndex].focus(true); - event.preventDefault(); - } - } - break; - case 40: // down - if (line.next) { - if (event.metaKey || event.ctrlKey) { - // [a, b, c, d] => [a, c, b, d] - - // A points to C - if (prevIndex) - this.firebaseRefs.script.child('lines/'+prevIndex).update({next: line.next}); - else - this.firebaseRefs.script.update({firstLine:line.next}); - var newNext = this.state.script.lines[line.next].next; - // C points to B - this.firebaseRefs.script.child('lines/'+line.next).update({next: index}); - // B points to D - if (newNext) - this.firebaseRefs.script.child('lines/'+index).update({ next: newNext }); - else - this.firebaseRefs.script.child('lines/'+index+'/next').remove(); - this.refs['line'+index].focus(); - event.preventDefault(); - } else if (cursorPos(event.target) >= event.target.textContent.length ) { - this.refs['line'+line.next].focus(); - event.preventDefault(); - } - } - break; - case 8: // backspace - if (!line.text && prevIndex) { - // update previous line - if (line.next) - this.firebaseRefs.script.child('lines/'+prevIndex).update({next:line.next}); - else - this.firebaseRefs.script.child('lines/'+prevIndex+'/next').remove(); - - // remove line - this.firebaseRefs.script.child('lines/'+index).remove(); - this.refs['line'+prevIndex].focus(true); - event.preventDefault(); - } - break; - case 13: // enter - if (line.text) { - // create new line pointing to current line's `next` - var newItem = { type: nextTypes[line.type] }; - if (line.next) newItem.next = line.next; - var newRef = this.firebaseRefs.script.child('lines').push(newItem); - // point current line to the new line - this.firebaseRefs.script.child('lines/'+index+'/next').set(newRef.key); - setTimeout((function(){ - this.refs['line'+newRef.key].focus(); - }).bind(this)); - } - } - }, - render: function() { - var indexes = {}; - var lines = []; - var previous = null, prevPrevious = null; - var next = (function(line, index){ - if (!line) { - console.warn('Line not found at index:', index); - return; - } - lines.push( - - ); - prevPrevious = previous; - previous = index; - if (line.next && this.state.script.lines[line.next]) next(this.state.script.lines[line.next], line.next); - }).bind(this); - - if (this.state.script && this.state.script.lines && this.state.script.firstLine) { - next(this.state.script.lines[this.state.script.firstLine], this.state.script.firstLine); - } else { - lines =

Loading Script...

- } - return ( -
-
- ); - } -}); - -var highlight = ''; - -var Line = React.createClass({ - mixins: [ReactFireMixin, StopPropagationMixin, ReactRouter.State], - getInitialState: function() { - return { - comments: this.props.line.comments, - commenting: false, - scriptId: this.getParams().scriptId, - focused: false, - }; - }, - componentWillMount: function() { - this.bindAsObject(new Firebase("https://screenwrite.firebaseio.com/"+this.state.scriptId+"/lines/" + this.props.index), "line"); - }, - handleChange: function(event) { - this.firebaseRefs.line.update({'text':event.target.value}); - }, - handleComment: function(event) { - this.firebaseRefs.line.update({'comment':event.target.value}); - }, - nextType: function(){ - var index = types.indexOf(this.props.line.type) + 1; - index = (index < types.length) ? index : 0; - this.setType(types[index]); - }, - prevType: function() { - var index = types.indexOf(this.props.line.type) - 1; - index = (index >= 0) ? index : types.length - 1; - this.setType(types[index]); - }, - setType: function(type) { - this.firebaseRefs.line.update({type:type}); - }, - handleKey: function(event) { - switch (event.keyCode) { - case 39: // right - if (~['character', 'scene'].indexOf(this.props.line.type) && cursorPos(event.target) >= event.target.textContent.length) { - var suggestion; - if (suggestion = this.props.getSuggestion(this.props.index)) { - this.firebaseRefs.line.update({ text: this.props.line.text + suggestion }, (function(){ - placeCaretAtEnd(this.refs.text.getDOMNode()); - }).bind(this)); - } - } - break; - case 13: // enter - event.preventDefault(); - if (this.props.line.text) { - break; - } - case 9: // tab - event.preventDefault(); - if (event.shiftKey) { - this.prevType(); - } else { - this.nextType(); - } - } - - this.props.onKeyDown(event, this.props.line, this.props.index, this.props.previous, this.props.prevPrevious); - }, - comment: function(event) { - event.stopPropagation(); - this.setState({ commenting: !this.state.commenting }, function(){ - if (this.state.commenting) { - var that = this; - document.addEventListener('click', function listener(){ - that.setState({ commenting: false }); - document.removeEventListener('click', listener); - }); - this.refs.commentBox.getDOMNode().focus(); - } - }); - }, - focus: function(atEnd) { - if (atEnd) - placeCaretAtEnd(this.refs.text.getDOMNode()); - else - this.refs.text.getDOMNode().focus(); - }, - onFocus: function(event) { - this.setState({focused:true}); - this.props.onFocus(event); - }, - onBlur: function(event) { - this.setState({focused:false}); - }, - render: function() { - var classes = { - line: true, - commented: this.props.line.comment, - highlight: highlight && this.props.line.text && highlight.toUpperCase()==this.props.line.text.toUpperCase() - }; - classes[this.props.line.type] = true; - classes = React.addons.classSet(classes); - - var line, suggest; - if (this.props.readonly) { - line =
; - } else { - if (this.state.focused) { - suggest = this.props.getSuggestion(this.props.index); - } - - line = - } - - return ( -
  • - {line} - - - - - {this.state.commenting && } -
  • - ); - } -}); - -var ContentEditable = React.createClass({ - shouldComponentUpdate: function(nextProps) { - // Only update if the html prop has changed AND it's different from what we have in the DOM - // This prevents cursor jumping when the user is typing (internal changes) - // but allows updates from external sources (autocomplete, Firebase updates, etc.) - var currentHTML = this.getDOMNode().innerHTML; - return nextProps.html !== currentHTML; - }, - stripPaste: function(e){ - // Strip formatting on paste - var tempDiv = document.createElement("DIV"); - var item = _.findWhere(e.clipboardData.items, { type: 'text/plain' }); - item.getAsString(function (value) { - tempDiv.innerHTML = value; - document.execCommand('inserttext', false, tempDiv.innerText); - }); - e.preventDefault(); - }, - emitChange: function(){ - var html = this.getDOMNode().innerHTML; - if (this.props.onChange && html !== this.lastHtml) { - - this.props.onChange({ - target: { - value: html - } - }); - } - this.lastHtml = html; - }, - render: function(){ - return
    ; - } -}); - -var Nav = React.createClass({ - mixins: [ReactFireMixin, StopPropagationMixin, ReactRouter.State], - getInitialState: function() { - return { - open: null, - script: {}, - scriptId: this.getParams().scriptId, - highlight: '' - }; - }, - componentWillMount: function() { - this.bindAsObject(new Firebase("https://screenwrite.firebaseio.com/"+this.state.scriptId), "script"); - }, - toggle: function(dropdown, event) { - var that = this; - if (this.state.open != dropdown) { - setTimeout((function(){ - document.addEventListener('click', function listener(){ - that.setState({ open: false }); - document.removeEventListener('click', listener); - }); - this.setState({ open: dropdown }); - }).bind(this)); - } - }, - setType: function(type) { - if (!this.props.editingIndex) return; - this.firebaseRefs.script.child('lines/'+this.props.editingIndex+'/type').set(type); - }, - print: function() { - window.print(); - }, - highlight: function(event) { - highlight = event.target.value; - this.setState({highlight: event.target.value}); - }, - handleChange: function(input, event) { - this.firebaseRefs.script.child(input).set(event.target.value); - }, - newScript: function(){ - var fb = new Firebase("https://screenwrite.firebaseio.com/"); - var newRef = fb.push(); - window.location.hash = '#/' + newRef.key; - window.location.reload(); // force firebase to reload - }, - render: function() { - if (!this.state.script) return
    ; - - if (this.state.script.title) - document.title = 'Screenwriter: ' + this.state.script.title; - - var editing = this.state.script.lines && this.state.script.lines[this.props.editingIndex] || {}; - if (this.state.open=='print') { - var characters = []; - _.each(_.uniq(_.map(_.pluck(_.where(this.state.script.lines, {type:'character'}), 'text'), function(character){ - return character && character.toUpperCase(); - })), function(character){ - if (character) - characters.push() - }); - } - return ( -
    -
    -
    -
      -
    • -
      - - - - - - - - - - Report Issues on GitHub - - -
      - {this.state.open == 'print' &&
      -
      -

      Print Script

      -
      -
      - -
      -
      - -
      -
      - -
      -
      - -
      -
      -
      } -
    • - {this.props.readonly || -
    • - - - {editing.type || 'Line Type'} - - - {this.state.open == 'line' &&
      -
      -
      - {types.map(function(type){ - return - {type} - - }, this)} -
      -
      } -
    • - } -
    -
    -
    -
    -

    {this.props.script.title}

    - {this.props.script.authors &&

    by

    } -

    {this.props.script.authors}

    - {this.state.highlight &&

    Character: {this.state.highlight.toUpperCase()}

    } -
    {this.props.script.leftAddress}
    -
    {this.props.script.rightAddress}
    -
    -
    - ); - } -}); - -var Home = React.createClass({ - newScript: function(){ - var fb = new Firebase("https://screenwrite.firebaseio.com/"); - var newRef = fb.push(); - window.location.hash = '#/' + newRef.key; - window.location.reload(); // force firebase to reload - }, - render: function() { - var commentStyles = { - color: '#dd0', - textShadow: '0 1px 1px #000', - fontSize: '120%' - }; - return ( -
    - -
    - -

    Screenwriter

    -

    - New Script -   - Demo Script -

    - -

    - Github Source Code -

    -
    - -

    Collaborate:

    -

    Share your custom URL with friends to collaborate or add /view to the end for readonly mode!

    - -

    Shortcuts:

    -

    - Enter Insert new line
    - (Shift+)Tab Cycle through line types
    - Up/Down Move through lines
    - Cmd/Ctrl+Up/Down Reorder lines
    - Right Autocomplete the character or scene
    -

    - -

    Comments:

    -

    Hover over a line and click comment button

    - -

    Notes:

    -

    Scripts are not secure, if someone can figure out your URL, they can edit it. Print to PDF if you want a permanent copy.

    -
    - ); - } - -}); - -var App = React.createClass({ - render: function() { - return ; - } -}); - -Route = ReactRouter.Route; -Link = ReactRouter.Link; -RouteHandler = ReactRouter.RouteHandler; -DefaultRoute = ReactRouter.DefaultRoute; -var routes = ( - - - - - -); - - -ReactRouter.run(routes, function (Handler) { - React.render(, document.getElementById('container')); -}); \ No newline at end of file +var root = createRoot(document.getElementById('container')); +root.render(); diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..9d8bfce --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Unified build script: bundles JS/JSX + SCSS + Bootstrap CSS via esbuild. +// Outputs: +// script.js – JavaScript bundle +// script.css – CSS bundle (Bootstrap + app styles + print styles) + +const esbuild = require('esbuild'); +const { sassPlugin } = require('esbuild-sass-plugin'); + +esbuild.build({ + entryPoints: ['script.jsx'], + bundle: true, + outfile: 'script.js', + platform: 'browser', + jsx: 'automatic', + plugins: [sassPlugin()], + loader: { + '.eot': 'file', + '.ttf': 'file', + '.woff': 'file', + '.woff2': 'file', + '.svg': 'file', + }, + assetNames: 'fonts/[name]-[hash]', + logLevel: 'info', +}).catch(function() { process.exit(1); }); diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..0bbfb47 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,18 @@ +import { HashRouter, Routes, Route } from 'react-router'; +import Home from './components/Home.jsx'; +import ScriptPage from './components/ScriptPage.jsx'; + +// Root App component with hash-based routing +function App() { + return ( + + + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/src/components/ContentEditable.jsx b/src/components/ContentEditable.jsx new file mode 100644 index 0000000..2a6c8d0 --- /dev/null +++ b/src/components/ContentEditable.jsx @@ -0,0 +1,72 @@ +import { forwardRef, useRef, useLayoutEffect } from 'react'; + +// ContentEditable – keeps cursor stable while the user types. +// The html prop is written to the DOM only when it genuinely differs from the +// current DOM content, preventing React from resetting the cursor position. +var ContentEditable = forwardRef(function ContentEditable(props, ref) { + var html = props.html; + var onChange = props.onChange; + var onKeyDown = props.onKeyDown; + var onClick = props.onClick; + var className = props.className; + var onFocus = props.onFocus; + var onBlur = props.onBlur; + var suggest = props.suggest; + + var domRef = useRef(null); + var lastHtml = useRef(html || ''); + + var setRef = function(el) { + domRef.current = el; + if (typeof ref === 'function') ref(el); + else if (ref) ref.current = el; + }; + + useLayoutEffect(function() { + if (!domRef.current) return; + if ((html || '') !== domRef.current.innerHTML) { + domRef.current.innerHTML = html || ''; + lastHtml.current = html || ''; + } + }); + + var emitChange = function() { + if (!domRef.current) return; + var currentHtml = domRef.current.innerHTML; + if (onChange && currentHtml !== lastHtml.current) { + onChange({ target: { value: currentHtml } }); + } + lastHtml.current = currentHtml; + }; + + var stripPaste = function(e) { + var items = Array.from(e.clipboardData.items); + var textItem = items.find(function(i) { return i.type === 'text/plain'; }); + if (textItem) { + var tempDiv = document.createElement('div'); + textItem.getAsString(function(value) { + tempDiv.innerHTML = value; + document.execCommand('inserttext', false, tempDiv.innerText); + }); + } + e.preventDefault(); + }; + + return ( +
    + ); +}); + +export default ContentEditable; diff --git a/src/components/Home.jsx b/src/components/Home.jsx new file mode 100644 index 0000000..0798621 --- /dev/null +++ b/src/components/Home.jsx @@ -0,0 +1,58 @@ +import { Link } from 'react-router'; +import { ref as dbRef, push } from 'firebase/database'; +import { db } from '../firebase.js'; + +// Home component – landing page shown at '#/'. +function Home() { + var commentStyles = { + color: '#dd0', + textShadow: '0 1px 1px #000', + fontSize: '120%' + }; + + var newScript = function() { + var newRef = push(dbRef(db)); + window.location.hash = '#/' + newRef.key; + window.location.reload(); + }; + + return ( +
    +
    +

    Screenwriter

    +

    + + New Script + +   + Demo Script +

    +

    + + Github Source Code + +

    +
    + +

    Collaborate:

    +

    Share your custom URL with friends to collaborate or add /view to the end for readonly mode!

    + +

    Shortcuts:

    +

    + Enter Insert new line
    + (Shift+)Tab Cycle through line types
    + Up/Down Move through lines
    + Cmd/Ctrl+Up/Down Reorder lines
    + Right Autocomplete the character or scene
    +

    + +

    Comments:

    +

    Hover over a line and click comment button

    + +

    Notes:

    +

    Scripts are not secure, if someone can figure out your URL, they can edit it. Print to PDF if you want a permanent copy.

    +
    + ); +} + +export default Home; diff --git a/src/components/Line.jsx b/src/components/Line.jsx new file mode 100644 index 0000000..58068b0 --- /dev/null +++ b/src/components/Line.jsx @@ -0,0 +1,152 @@ +import { forwardRef, useRef, useState, useImperativeHandle } from 'react'; +import { ref as dbRef, update } from 'firebase/database'; +import { db } from '../firebase.js'; +import { types } from '../constants.js'; +import { cursorPos, placeCaretAtEnd } from '../utils.js'; +import ContentEditable from './ContentEditable.jsx'; + +// Line component – renders a single screenplay line and exposes a focus() method. +var Line = forwardRef(function Line(props, ref) { + var line = props.line; + var index = props.index; + var previous = props.previous; + var prevPrevious = props.prevPrevious; + var onFocusProp = props.onFocus; + var getSuggestion = props.getSuggestion; + var readonly = props.readonly; + var onKeyDown = props.onKeyDown; + var scriptId = props.scriptId; + var highlight = props.highlight; + + var focused = useState(false); + var setFocused = focused[1]; + focused = focused[0]; + + var commentingState = useState(false); + var setCommenting = commentingState[1]; + var commenting = commentingState[0]; + + var textRef = useRef(null); + var commentBoxRef = useRef(null); + var firebaseLineRef = useRef(dbRef(db, scriptId + '/lines/' + index)); + + useImperativeHandle(ref, function() { + return { + focus: function(atEnd) { + if (textRef.current) { + if (atEnd) placeCaretAtEnd(textRef.current); + else textRef.current.focus(); + } + } + }; + }); + + var handleChange = function(event) { + update(firebaseLineRef.current, { text: event.target.value }); + }; + + var handleComment = function(event) { + update(firebaseLineRef.current, { comment: event.target.value }); + }; + + var nextTypeAction = function() { + var idx = types.indexOf(line.type) + 1; + update(firebaseLineRef.current, { type: idx < types.length ? types[idx] : types[0] }); + }; + + var prevTypeAction = function() { + var idx = types.indexOf(line.type) - 1; + update(firebaseLineRef.current, { type: idx >= 0 ? types[idx] : types[types.length - 1] }); + }; + + var handleKey = function(event) { + switch (event.keyCode) { + case 39: { // right – autocomplete + if (~['character', 'scene'].indexOf(line.type) && cursorPos(event.target) >= event.target.textContent.length) { + var suggestion = getSuggestion(index); + if (suggestion) { + update(firebaseLineRef.current, { text: line.text + suggestion }).then(function() { + if (textRef.current) placeCaretAtEnd(textRef.current); + }); + } + } + break; + } + case 13: // enter + event.preventDefault(); + if (line.text) break; + // fall through to tab + /* falls through */ + case 9: // tab + event.preventDefault(); + if (event.shiftKey) prevTypeAction(); + else nextTypeAction(); + } + onKeyDown(event, line, index, previous, prevPrevious); + }; + + var handleCommentToggle = function(event) { + event.stopPropagation(); + setCommenting(function(c) { + var next = !c; + if (next) { + setTimeout(function() { + var listener = function() { + setCommenting(false); + document.removeEventListener('click', listener); + }; + document.addEventListener('click', listener); + if (commentBoxRef.current) commentBoxRef.current.focus(); + }, 0); + } + return next; + }); + }; + + var classes = [ + 'line', + line.type, + line.comment ? 'commented' : null, + highlight && line.text && highlight.toUpperCase() === line.text.toUpperCase() ? 'highlight' : null + ].filter(Boolean).join(' '); + + var suggest; + var lineElement; + if (readonly) { + lineElement =
    ; + } else { + if (focused) suggest = getSuggestion(index); + lineElement = ( + + ); + } + + return ( +
  • + {lineElement} + + + + {commenting && ( + + )} +
  • + ); +}); + +export default Line; diff --git a/src/components/Nav.jsx b/src/components/Nav.jsx new file mode 100644 index 0000000..fa19fa7 --- /dev/null +++ b/src/components/Nav.jsx @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import { ref as dbRef, set, push } from 'firebase/database'; +import { db } from '../firebase.js'; +import { types } from '../constants.js'; + +// Nav component – top navigation bar with title, print options and line-type picker. +function Nav(props) { + var script = props.script; + var editingIndex = props.editingIndex; + var readonly = props.readonly; + var scriptId = props.scriptId; + var highlight = props.highlight; + var onHighlightChange = props.onHighlightChange; + + var openState = useState(null); + var setOpen = openState[1]; + var open = openState[0]; + + var toggle = function(dropdown) { + if (open !== dropdown) { + setTimeout(function() { + var listener = function() { + setOpen(null); + document.removeEventListener('click', listener); + }; + document.addEventListener('click', listener); + }, 0); + setOpen(dropdown); + } + }; + + var setType = function(type) { + if (!editingIndex) return; + set(dbRef(db, scriptId + '/lines/' + editingIndex + '/type'), type); + }; + + var handleFieldChange = function(field, event) { + set(dbRef(db, scriptId + '/' + field), event.target.value); + }; + + var newScript = function() { + var newRef = push(dbRef(db)); + window.location.hash = '#/' + newRef.key; + window.location.reload(); + }; + + if (!script) return
    ; + + if (script.title) document.title = 'Screenwriter: ' + script.title; + + var editing = (script.lines && script.lines[editingIndex]) || {}; + + var characters = []; + if (open === 'print' && script.lines) { + var seen = {}; + Object.values(script.lines).forEach(function(l) { + if (l && l.type === 'character' && l.text) { + var upper = l.text.toUpperCase(); + if (!seen[upper]) { + seen[upper] = true; + characters.push(); + } + } + }); + } + + return ( +
    +
    +
    +
      +
    • +
      + + + + + + + + + + Report Issues on GitHub + + +
      + {open === 'print' && ( +
      +
      +

      + Print Script +

      +
      +
      +