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.
-
+ if (script.firstLine) iterate(script.firstLine);
+ return (suggestions.pop() || '').substr(text.length);
+ }, [script]);
+
+ var focusLine = function(index, atEnd) {
+ var r = lineRefs.current[index];
+ if (r && r.current) r.current.focus(atEnd || false);
+ };
+
+ var handleKey = useCallback(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]
+ if (prevPrevIndex) update(dbRef(db, scriptId + '/lines/' + prevPrevIndex), { next: index });
+ else update(dbRef(db, scriptId), { firstLine: index });
+ var nextIndexBeforeSwap = line.next;
+ update(dbRef(db, scriptId + '/lines/' + index), { next: prevIndex });
+ if (line.next) update(dbRef(db, scriptId + '/lines/' + prevIndex), { next: nextIndexBeforeSwap });
+ else remove(dbRef(db, scriptId + '/lines/' + prevIndex + '/next'));
+ setTimeout(function() { focusLine(index, true); }, 0);
+ event.preventDefault();
+ } else if (!cursorPos(event.target)) {
+ focusLine(prevIndex, true);
+ event.preventDefault();
+ }
+ }
+ break;
+ case 40: // down
+ if (line.next) {
+ if (event.metaKey || event.ctrlKey) {
+ // [a, b, c, d] => [a, c, b, d]
+ if (prevIndex) update(dbRef(db, scriptId + '/lines/' + prevIndex), { next: line.next });
+ else update(dbRef(db, scriptId), { firstLine: line.next });
+ var nextLineData = script && script.lines && script.lines[line.next];
+ var nextIndexAfterCurrent = nextLineData && nextLineData.next;
+ update(dbRef(db, scriptId + '/lines/' + line.next), { next: index });
+ if (nextIndexAfterCurrent) update(dbRef(db, scriptId + '/lines/' + index), { next: nextIndexAfterCurrent });
+ else remove(dbRef(db, scriptId + '/lines/' + index + '/next'));
+ setTimeout(function() { focusLine(index, false); }, 0);
+ event.preventDefault();
+ } else if (cursorPos(event.target) >= event.target.textContent.length) {
+ focusLine(line.next, false);
+ event.preventDefault();
+ }
+ }
+ break;
+ case 8: // backspace
+ if (!line.text && prevIndex) {
+ if (line.next) update(dbRef(db, scriptId + '/lines/' + prevIndex), { next: line.next });
+ else remove(dbRef(db, scriptId + '/lines/' + prevIndex + '/next'));
+ remove(dbRef(db, scriptId + '/lines/' + index));
+ setTimeout(function() { focusLine(prevIndex, true); }, 0);
+ event.preventDefault();
+ }
+ break;
+ case 13: // enter
+ if (line.text) {
+ var newItem = { type: nextTypes[line.type] };
+ if (line.next) newItem.next = line.next;
+ var newLineRef = push(dbRef(db, scriptId + '/lines'), newItem);
+ set(dbRef(db, scriptId + '/lines/' + index + '/next'), newLineRef.key);
+ setTimeout(function() { focusLine(newLineRef.key, false); }, 0);
+ }
+ break;
+ }
+ }, [scriptId, script]);
+
+ // Build the ordered array of line elements from the linked list
+ var lineElements = [];
+ var previous = null;
+ var prevPrevious = null;
+
+ var iterateLines = function(line, index) {
+ if (!line) return;
+ if (!lineRefs.current[index]) lineRefs.current[index] = { current: null };
+ var lineRef = lineRefs.current[index];
+
+ lineElements.push(
+
);
+ prevPrevious = previous;
+ previous = index;
+ if (line.next && script.lines[line.next]) iterateLines(script.lines[line.next], line.next);
+ };
+
+ if (script && script.lines && script.firstLine) {
+ iterateLines(script.lines[script.firstLine], script.firstLine);
}
-});
+ return (
+
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.
+
+ );
+}
+
+// Root App component with hash-based routing
+function App() {
+ 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
+// Mount the React app
+var root = createRoot(document.getElementById('container'));
+root.render();
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index adebdef..06c36f5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18,22 +18,6 @@
"@csstools/css-tokenizer" "^3.0.3"
lru-cache "^10.4.3"
-"@babel/cli@^7.28.6":
- version "7.28.6"
- resolved "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz"
- integrity sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==
- dependencies:
- "@jridgewell/trace-mapping" "^0.3.28"
- commander "^6.2.0"
- convert-source-map "^2.0.0"
- fs-readdir-recursive "^1.1.0"
- glob "^7.2.0"
- make-dir "^2.1.0"
- slash "^2.0.0"
- optionalDependencies:
- "@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents.3"
- chokidar "^3.6.0"
-
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
version "7.29.0"
resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz"
@@ -1011,6 +995,11 @@
resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz"
integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==
+"@esbuild/linux-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz"
+ integrity sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==
+
"@firebase/ai@2.8.0":
version "2.8.0"
resolved "https://registry.npmjs.org/@firebase/ai/-/ai-2.8.0.tgz"
@@ -1727,16 +1716,16 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
-"@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3":
- version "2.1.8-no-fsevents.3"
- resolved "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz"
- integrity sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==
-
"@parcel/watcher-linux-x64-glibc@2.5.6":
version "2.5.6"
resolved "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz"
integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==
+"@parcel/watcher-linux-x64-musl@2.5.6":
+ version "2.5.6"
+ resolved "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz"
+ integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==
+
"@parcel/watcher@^2.4.1":
version "2.5.6"
resolved "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz"
@@ -1981,6 +1970,11 @@
resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz"
integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==
+"@unrs/resolver-binding-linux-x64-musl@1.11.1":
+ version "1.11.1"
+ resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz"
+ integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==
+
accepts@~1.3.8:
version "1.3.8"
resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz"
@@ -2459,7 +2453,7 @@ char-regex@^1.0.2:
resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz"
integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
-chokidar@^3.5.3, chokidar@^3.6.0:
+chokidar@^3.5.3:
version "3.6.0"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
@@ -2546,11 +2540,6 @@ color-support@^1.1.3:
resolved "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
-commander@^6.2.0:
- version "6.2.1"
- resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz"
- integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
-
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
@@ -2814,6 +2803,38 @@ error-ex@^1.2.0, error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
+esbuild@^0.27.4:
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz"
+ integrity sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==
+ optionalDependencies:
+ "@esbuild/aix-ppc64" "0.27.4"
+ "@esbuild/android-arm" "0.27.4"
+ "@esbuild/android-arm64" "0.27.4"
+ "@esbuild/android-x64" "0.27.4"
+ "@esbuild/darwin-arm64" "0.27.4"
+ "@esbuild/darwin-x64" "0.27.4"
+ "@esbuild/freebsd-arm64" "0.27.4"
+ "@esbuild/freebsd-x64" "0.27.4"
+ "@esbuild/linux-arm" "0.27.4"
+ "@esbuild/linux-arm64" "0.27.4"
+ "@esbuild/linux-ia32" "0.27.4"
+ "@esbuild/linux-loong64" "0.27.4"
+ "@esbuild/linux-mips64el" "0.27.4"
+ "@esbuild/linux-ppc64" "0.27.4"
+ "@esbuild/linux-riscv64" "0.27.4"
+ "@esbuild/linux-s390x" "0.27.4"
+ "@esbuild/linux-x64" "0.27.4"
+ "@esbuild/netbsd-arm64" "0.27.4"
+ "@esbuild/netbsd-x64" "0.27.4"
+ "@esbuild/openbsd-arm64" "0.27.4"
+ "@esbuild/openbsd-x64" "0.27.4"
+ "@esbuild/openharmony-arm64" "0.27.4"
+ "@esbuild/sunos-x64" "0.27.4"
+ "@esbuild/win32-arm64" "0.27.4"
+ "@esbuild/win32-ia32" "0.27.4"
+ "@esbuild/win32-x64" "0.27.4"
+
escalade@^3.1.1, escalade@^3.2.0:
version "3.2.0"
resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz"
@@ -3109,11 +3130,6 @@ fs-mkdirp-stream@^2.0.1:
graceful-fs "^4.2.8"
streamx "^2.12.0"
-fs-readdir-recursive@^1.1.0:
- version "1.1.0"
- resolved "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz"
- integrity sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==
-
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
@@ -3197,7 +3213,7 @@ glob@^10.3.10:
package-json-from-dist "^1.0.0"
path-scurry "^1.11.1"
-glob@^7.1.4, glob@^7.2.0:
+glob@^7.1.4:
version "7.2.3"
resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz"
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
@@ -4348,14 +4364,6 @@ lz-string@^1.5.0:
resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
-make-dir@^2.1.0:
- version "2.1.0"
- resolved "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz"
- integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==
- dependencies:
- pify "^4.0.1"
- semver "^5.6.0"
-
make-dir@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz"
@@ -4798,11 +4806,6 @@ pify@^2.0.0:
resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
-pify@^4.0.1:
- version "4.0.1"
- resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
- integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
-
pinkie-promise@^2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz"
@@ -5225,11 +5228,6 @@ semver-greatest-satisfied-range@^2.0.0:
dependencies:
sver "^1.8.3"
-semver@^5.6.0:
- version "5.7.2"
- resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
- integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
-
semver@^6.3.0, semver@^6.3.1:
version "6.3.1"
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
@@ -5329,11 +5327,6 @@ signal-exit@^4.0.1:
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
-slash@^2.0.0:
- version "2.0.0"
- resolved "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz"
- integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
-
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
From 288a7d6e95548c02a611aba7a3a51b88fa71c548 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 16 Mar 2026 02:28:30 +0000
Subject: [PATCH 3/3] Split script.jsx into src/ components and fix CSS
bundling with Bootstrap 3 from npm
Co-authored-by: ProLoser <67395+ProLoser@users.noreply.github.com>
---
.gitignore | 3 +-
index.html | 6 +-
package.json | 11 +-
script.jsx | 735 +----------------------------
scripts/build.js | 26 +
src/App.jsx | 18 +
src/components/ContentEditable.jsx | 72 +++
src/components/Home.jsx | 58 +++
src/components/Line.jsx | 152 ++++++
src/components/Nav.jsx | 206 ++++++++
src/components/ScriptPage.jsx | 193 ++++++++
src/constants.js | 12 +
src/firebase.js | 5 +
src/utils.js | 41 ++
yarn.lock | 34 +-
15 files changed, 828 insertions(+), 744 deletions(-)
create mode 100644 scripts/build.js
create mode 100644 src/App.jsx
create mode 100644 src/components/ContentEditable.jsx
create mode 100644 src/components/Home.jsx
create mode 100644 src/components/Line.jsx
create mode 100644 src/components/Nav.jsx
create mode 100644 src/components/ScriptPage.jsx
create mode 100644 src/constants.js
create mode 100644 src/firebase.js
create mode 100644 src/utils.js
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 eed812a..add8421 100644
--- a/index.html
+++ b/index.html
@@ -4,10 +4,8 @@
Screenwriter
-
-
-
-
+
+
diff --git a/package.json b/package.json
index 681a180..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": {
- "esbuild": "^0.27.4",
"@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": "esbuild script.jsx --bundle --outfile=script.js --platform=browser --jsx=automatic",
- "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 2774475..e86f201 100644
--- a/script.jsx
+++ b/script.jsx
@@ -1,731 +1,10 @@
-import { createRoot } from 'react-dom/client';
-import { HashRouter, Routes, Route, Link, useParams } from 'react-router';
-import { useState, useEffect, useRef, forwardRef, useImperativeHandle, useLayoutEffect, useCallback } from 'react';
-import { initializeApp } from 'firebase/app';
-import { getDatabase, ref as dbRef, onValue, set, push, update, remove } from 'firebase/database';
-
-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'
-};
-
-// Firebase setup using the modern modular SDK
-var firebaseApp = initializeApp({ databaseURL: 'https://screenwrite.firebaseio.com' });
-var db = getDatabase(firebaseApp);
-
-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;
-}
-
-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();
- }
-}
-
-// ContentEditable component – keeps cursor stable while user types
-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;
- };
-
- // Sync the html prop to the DOM only when it differs from what is already there.
- // Using useLayoutEffect ensures the content is set before the browser paints
- // (avoiding a flash of empty content on first mount), while the comparison
- // prevents resetting innerHTML – and thus the cursor – when the user is typing.
- 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 (
-
- );
-});
-
-// Line component – renders a single script 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 lineElement;
- var suggest;
- if (readonly) {
- lineElement = ;
- } else {
- if (focused) suggest = getSuggestion(index);
- lineElement = (
-
- );
- }
-
- return (
-
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.
-
- );
-}
-
-// Root App component with hash-based routing
-function App() {
- return (
-
-
- } />
- } />
- } />
-
-
- );
-}
+import { createRoot } from 'react-dom/client';
+import App from './src/App.jsx';
-// Mount the React app
var root = createRoot(document.getElementById('container'));
-root.render();
\ No newline at end of file
+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 (
+
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 (
+