testts / index.html
abeea's picture
Update index.html
dbc7994 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Screenplay Comparator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Courier+Prime:ital,wght@0,400;0,700;1,400&family=Bebas+Neue&display=swap');
:root {
--bg: #0d0d0d;
--surface: #141414;
--panel: #1a1a1a;
--border: #2a2a2a;
--gold: #c9a84c;
--gold-dim: #7a6230;
--green: #2d6a4f;
--green-hl: #1b4332;
--green-txt: #95d5b2;
--red: #7b2d2d;
--red-hl: #4a1010;
--red-txt: #ffb3b3;
--text: #e8e0d0;
--muted: #666;
--scene-bg: #1e1a0e;
--scene-bdr: #c9a84c44;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Courier Prime', monospace;
overflow: hidden;
}
/* ── HEADER ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: relative;
z-index: 10;
flex-shrink: 0;
}
.brand {
font-family: 'Bebas Neue', sans-serif;
font-size: 1.6rem;
letter-spacing: 3px;
color: var(--gold);
}
.brand span { color: var(--muted); font-size: 0.9rem; letter-spacing: 1px; font-family: 'Courier Prime', monospace; display: block; margin-top: -4px; }
.legend {
display: flex;
gap: 20px;
font-size: 0.75rem;
letter-spacing: 1px;
}
.legend-item { display: flex; align-items: center; gap: 7px; }
.legend-dot { width: 12px; height: 12px; border-radius: 2px; }
.legend-dot.match { background: var(--green-txt); }
.legend-dot.change { background: var(--red-txt); }
.legend-dot.scene { background: var(--gold); }
/* ── STATS BAR ── */
#stats-bar {
display: none;
padding: 10px 28px;
background: var(--scene-bg);
border-bottom: 1px solid var(--scene-bdr);
flex-shrink: 0;
gap: 32px;
align-items: center;
font-size: 0.78rem;
letter-spacing: 0.5px;
}
#stats-bar.visible { display: flex; }
.stat { text-align: center; }
.stat-val { font-family: 'Bebas Neue', sans-serif; font-size: 1.5rem; color: var(--gold); line-height: 1; }
.stat-lbl { color: var(--muted); margin-top: 2px; }
.stat-divider { width: 1px; height: 36px; background: var(--border); }
/* ── UPLOAD ZONE ── */
#upload-view {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 48px;
padding: 40px;
overflow: auto;
}
.upload-headline { text-align: center; }
.upload-headline h2 {
font-family: 'Playfair Display', serif;
font-size: 2.2rem;
color: var(--gold);
margin-bottom: 8px;
}
.upload-headline p { color: var(--muted); font-size: 0.85rem; letter-spacing: 1px; }
.upload-row { display: flex; gap: 28px; width: 100%; max-width: 800px; }
.upload-card {
flex: 1;
border: 1px dashed var(--border);
border-radius: 4px;
padding: 36px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
cursor: pointer;
transition: border-color .2s, background .2s;
position: relative;
text-align: center;
}
.upload-card:hover, .upload-card.drag-over { border-color: var(--gold); background: #ffffff05; }
.upload-card.loaded { border-color: var(--green-txt); border-style: solid; background: #2d6a4f15; }
.upload-card input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.upload-icon { font-size: 2.4rem; line-height: 1; }
.upload-label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; letter-spacing: 2px; }
.upload-card:nth-child(1) .upload-label { color: var(--gold); }
.upload-card:nth-child(2) .upload-label { color: #a0b4c8; }
.upload-sub { color: var(--muted); font-size: 0.75rem; letter-spacing: 0.5px; }
.file-name { font-size: 0.8rem; color: var(--green-txt); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#compare-btn {
display: none;
font-family: 'Bebas Neue', sans-serif;
font-size: 1.1rem;
letter-spacing: 3px;
background: var(--gold);
color: #0d0d0d;
border: none;
padding: 14px 48px;
cursor: pointer;
border-radius: 2px;
transition: background .2s, transform .1s;
}
#compare-btn:hover { background: #e0b85a; }
#compare-btn:active { transform: scale(.97); }
#compare-btn.visible { display: block; }
/* ── COMPARE VIEW ── */
#compare-view {
display: none;
flex: 1;
overflow: hidden;
flex-direction: column;
}
#compare-view.visible { display: flex; }
/* ── COLUMN HEADERS with per-side nav ── */
.col-headers {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.col-header {
padding: 8px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.col-header:first-child { border-right: 1px solid var(--border); }
.col-header-title {
font-family: 'Bebas Neue', sans-serif;
letter-spacing: 2px;
font-size: 0.9rem;
}
.col-header:first-child .col-header-title { color: var(--gold); }
.col-header:last-child .col-header-title { color: #a0b4c8; }
.col-header .fname { color: var(--muted); font-family: 'Courier Prime', monospace; font-size: 0.72rem; letter-spacing: 0; }
/* per-side page nav */
.side-nav {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.side-nav-btn {
font-family: 'Courier Prime', monospace;
font-size: 0.8rem;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 3px 9px;
cursor: pointer;
border-radius: 2px;
transition: border-color .15s, color .15s;
line-height: 1.4;
}
.side-nav-btn:hover:not(:disabled) { border-color: var(--gold); color: var(--gold); }
.side-nav-btn:disabled { opacity: 0.3; cursor: default; }
.side-page-indicator {
font-size: 0.72rem;
color: var(--muted);
min-width: 60px;
text-align: center;
letter-spacing: 0.5px;
white-space: nowrap;
}
/* columns body */
.columns-wrap {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
overflow: hidden;
}
.col-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.col-panel:first-child { border-right: 1px solid var(--border); }
/* The actual scrollable page content */
.col-scroll {
flex: 1;
overflow-y: auto;
padding: 24px 20px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
position: relative;
}
.col-scroll::-webkit-scrollbar { width: 5px; }
.col-scroll::-webkit-scrollbar-track { background: transparent; }
.col-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* screenplay blocks */
.block {
margin-bottom: 6px;
padding: 5px 8px;
border-radius: 2px;
line-height: 1.7;
font-size: 0.84rem;
white-space: pre-wrap;
transition: background .15s;
}
.block.scene-heading {
font-family: 'Courier Prime', monospace;
font-weight: 700;
letter-spacing: 1.5px;
color: var(--gold);
background: var(--scene-bg);
border-left: 3px solid var(--gold);
padding-left: 12px;
margin-top: 18px;
margin-bottom: 2px;
}
.block.match { background: var(--green-hl); color: var(--green-txt); }
.block.changed { background: var(--red-hl); color: var(--red-txt); }
.block.neutral { color: var(--text); }
/* empty page placeholder */
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted);
font-size: 0.8rem;
letter-spacing: 1px;
gap: 8px;
opacity: 0.5;
}
.page-empty-icon { font-size: 2rem; }
/* page fade animation */
@keyframes pageFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.page-content { animation: pageFadeIn .18s ease; }
/* ── TOOLBAR ── */
#toolbar {
display: none;
padding: 8px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
gap: 12px;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
}
#toolbar.visible { display: flex; }
.tb-btn {
font-family: 'Courier Prime', monospace;
font-size: 0.75rem;
letter-spacing: 1px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 5px 14px;
cursor: pointer;
border-radius: 2px;
transition: border-color .15s, color .15s;
}
.tb-btn:hover { border-color: var(--gold); color: var(--gold); }
.tb-btn.active { border-color: var(--gold); color: var(--gold); background: #c9a84c18; }
.tb-sep { width: 1px; height: 20px; background: var(--border); }
.tb-label { font-size: 0.7rem; color: var(--muted); letter-spacing: 1px; }
#scene-jump {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
font-family: 'Courier Prime', monospace;
font-size: 0.75rem;
padding: 5px 10px;
border-radius: 2px;
max-width: 200px;
}
#scene-jump option { background: var(--panel); }
/* ── BOTTOM PAGINATION BAR ── */
#page-bar {
display: none;
flex-shrink: 0;
padding: 10px 20px;
background: var(--surface);
border-top: 1px solid var(--border);
align-items: center;
justify-content: center;
gap: 14px;
}
#page-bar.visible { display: flex; }
.page-btn {
font-family: 'Bebas Neue', sans-serif;
font-size: 1rem;
letter-spacing: 2px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 7px 22px;
cursor: pointer;
border-radius: 2px;
transition: border-color .15s, color .15s, background .15s;
display: flex;
align-items: center;
gap: 8px;
}
.page-btn:hover:not(:disabled) { border-color: var(--gold); color: var(--gold); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-indicator {
font-family: 'Bebas Neue', sans-serif;
font-size: 1rem;
letter-spacing: 2px;
color: var(--gold);
min-width: 100px;
text-align: center;
}
.sync-status {
font-size: 0.72rem;
letter-spacing: 1px;
padding: 4px 10px;
border-radius: 2px;
border: 1px solid var(--border);
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.sync-status.synced { color: var(--green-txt); border-color: var(--green); background: #2d6a4f22; }
.sync-status.unsynced { color: var(--muted); }
.sync-status:hover { border-color: var(--gold); color: var(--gold); }
/* loading overlay */
#loader {
display: none;
position: fixed;
inset: 0;
background: #0d0d0dcc;
z-index: 999;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
}
#loader.visible { display: flex; }
.loader-text { font-family: 'Bebas Neue', sans-serif; font-size: 1.4rem; letter-spacing: 4px; color: var(--gold); animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:.4} 50%{opacity:1} }
.loader-bar { width: 200px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; }
.loader-fill { height: 100%; width: 30%; background: var(--gold); animation: slide 1.2s ease-in-out infinite; }
@keyframes slide { 0%{transform:translateX(-100%)} 100%{transform:translateX(450%)} }
#reset-btn {
margin-left: auto;
font-family: 'Courier Prime', monospace;
font-size: 0.72rem;
letter-spacing: 1px;
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 12px;
cursor: pointer;
border-radius: 2px;
transition: border-color .15s, color .15s;
}
#reset-btn:hover { border-color: var(--red-txt); color: var(--red-txt); }
.app-wrap { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
</style>
</head>
<body>
<div class="app-wrap">
<!-- HEADER -->
<header>
<div class="brand">
SCRIPT DIFF
<span>Screenplay Comparator</span>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot match"></div> Unchanged</div>
<div class="legend-item"><div class="legend-dot change"></div> Cut / Modified</div>
<div class="legend-item"><div class="legend-dot scene"></div> Scene Heading</div>
</div>
</header>
<!-- STATS BAR -->
<div id="stats-bar">
<div class="stat"><div class="stat-val" id="st-scenes-1">β€”</div><div class="stat-lbl">Scenes (Draft 1)</div></div>
<div class="stat-divider"></div>
<div class="stat"><div class="stat-val" id="st-scenes-2">β€”</div><div class="stat-lbl">Scenes (Final)</div></div>
<div class="stat-divider"></div>
<div class="stat"><div class="stat-val" id="st-scenes-cut">β€”</div><div class="stat-lbl">Scenes Cut</div></div>
<div class="stat-divider"></div>
<div class="stat"><div class="stat-val" id="st-lines-1">β€”</div><div class="stat-lbl">Lines (Draft 1)</div></div>
<div class="stat-divider"></div>
<div class="stat"><div class="stat-val" id="st-lines-2">β€”</div><div class="stat-lbl">Lines (Final)</div></div>
<div class="stat-divider"></div>
<div class="stat"><div class="stat-val" id="st-lines-cut">β€”</div><div class="stat-lbl">Lines Cut</div></div>
<div class="stat-divider"></div>
<div class="stat"><div class="stat-val" id="st-match-pct">β€”</div><div class="stat-lbl">Unchanged %</div></div>
<div class="stat-divider"></div>
<div class="stat"><div class="stat-val" id="st-pages">β€”</div><div class="stat-lbl">Pages</div></div>
</div>
<!-- UPLOAD VIEW -->
<div id="upload-view">
<div class="upload-headline">
<h2>Compare Two Screenplays</h2>
<p>Upload your first draft and final draft β€” page by page, side by side</p>
</div>
<div class="upload-row">
<div class="upload-card" id="card1" ondragover="onDrag(event,1)" ondragleave="offDrag(1)" ondrop="onDrop(event,1)">
<input type="file" accept=".docx" id="file1" onchange="loadFile(1)">
<div class="upload-icon">πŸ“„</div>
<div class="upload-label">First Draft</div>
<div class="upload-sub">Your original screenplay</div>
<div class="file-name" id="name1">Drop .docx here or click</div>
</div>
<div class="upload-card" id="card2" ondragover="onDrag(event,2)" ondragleave="offDrag(2)" ondrop="onDrop(event,2)">
<input type="file" accept=".docx" id="file2" onchange="loadFile(2)">
<div class="upload-icon">🎬</div>
<div class="upload-label">Final Draft</div>
<div class="upload-sub">Your revised / final version</div>
<div class="file-name" id="name2">Drop .docx here or click</div>
</div>
</div>
<button id="compare-btn" onclick="runComparison()">β–Ά RUN COMPARISON</button>
</div>
<!-- TOOLBAR (shown after comparison) -->
<div id="toolbar">
<span class="tb-label">FILTER:</span>
<button class="tb-btn active" onclick="setFilter('all', this)">All Lines</button>
<button class="tb-btn" onclick="setFilter('changed', this)">Changes Only</button>
<button class="tb-btn" onclick="setFilter('match', this)">Matches Only</button>
<div class="tb-sep"></div>
<span class="tb-label">JUMP TO:</span>
<select id="scene-jump" onchange="jumpToScene(this.value)">
<option value="">β€” Scene β€”</option>
</select>
<button id="reset-btn" onclick="resetTool()">βœ• Reset</button>
</div>
<!-- COMPARE VIEW -->
<div id="compare-view">
<!-- Column headers with per-side navigation -->
<div class="col-headers">
<div class="col-header">
<div>
<div class="col-header-title">First Draft</div>
<div class="fname" id="lbl1"></div>
</div>
<div class="side-nav">
<button class="side-nav-btn" id="left-prev" onclick="goPageSide('left', -1)" title="Previous page (left only)">β—€</button>
<div class="side-page-indicator" id="left-page-ind">Pg 1 / 1</div>
<button class="side-nav-btn" id="left-next" onclick="goPageSide('left', +1)" title="Next page (left only)">β–Ά</button>
</div>
</div>
<div class="col-header">
<div>
<div class="col-header-title">Final Draft</div>
<div class="fname" id="lbl2"></div>
</div>
<div class="side-nav">
<button class="side-nav-btn" id="right-prev" onclick="goPageSide('right', -1)" title="Previous page (right only)">β—€</button>
<div class="side-page-indicator" id="right-page-ind">Pg 1 / 1</div>
<button class="side-nav-btn" id="right-next" onclick="goPageSide('right', +1)" title="Next page (right only)">β–Ά</button>
</div>
</div>
</div>
<!-- Side-by-side page content -->
<div class="columns-wrap">
<div class="col-panel">
<div class="col-scroll" id="col-left"></div>
</div>
<div class="col-panel">
<div class="col-scroll" id="col-right"></div>
</div>
</div>
</div>
<!-- BOTTOM PAGE NAVIGATION BAR -->
<div id="page-bar">
<button class="page-btn" id="btn-prev" onclick="goPage(-1)" disabled>β—€ PREV PAGE</button>
<div class="page-indicator" id="page-indicator">PAGE 1 / 1</div>
<button class="page-btn" id="btn-next" onclick="goPage(+1)" disabled>NEXT PAGE β–Ά</button>
<div class="tb-sep" style="height:24px"></div>
<button class="sync-status synced" id="sync-toggle" onclick="toggleSync()" title="Click to toggle sync">β‡… PAGES SYNCED</button>
</div>
</div>
<!-- LOADER -->
<div id="loader">
<div class="loader-text">Analyzing Script…</div>
<div class="loader-bar"><div class="loader-fill"></div></div>
</div>
<script>
// ── State ──
const files = { 1: null, 2: null };
let pagesLeft = []; // array of arrays of diff items
let pagesRight = [];
let pageLeft = 0; // current page index (0-based)
let pageRight = 0;
let synced = true;
let currentFilter = 'all';
const LINES_PER_PAGE = 40; // lines per "screenplay page"
// ── Drag & Drop ──
function onDrag(e, n) { e.preventDefault(); document.getElementById('card'+n).classList.add('drag-over'); }
function offDrag(n) { document.getElementById('card'+n).classList.remove('drag-over'); }
function onDrop(e, n) {
e.preventDefault(); offDrag(n);
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith('.docx')) { files[n] = f; markLoaded(n, f.name); }
}
function loadFile(n) {
const inp = document.getElementById('file'+n);
if (!inp.files[0]) return;
files[n] = inp.files[0];
markLoaded(n, inp.files[0].name);
}
function markLoaded(n, name) {
document.getElementById('card'+n).classList.add('loaded');
document.getElementById('name'+n).textContent = name;
if (files[1] && files[2]) document.getElementById('compare-btn').classList.add('visible');
}
// ── Extract text from docx ──
async function extractLines(file) {
return new Promise((res, rej) => {
const reader = new FileReader();
reader.onload = async e => {
try {
const result = await mammoth.extractRawText({ arrayBuffer: e.target.result });
const lines = result.value.split('\n').map(l => l.trim()).filter(l => l.length > 0);
res(lines);
} catch(err) { rej(err); }
};
reader.readAsArrayBuffer(file);
});
}
// ── Scene heading detection ──
function isSceneHeading(line) {
return /^(INT\.|EXT\.|INT\/EXT\.|I\/E\.|INTERCUT|FADE IN|FADE OUT|SMASH CUT|CUT TO|DISSOLVE)/i.test(line.trim())
|| /^[A-Z\s\d\.\-\/:']+$/.test(line.trim()) && line.trim().length > 4 && line.trim().length < 80;
}
// ── LCS-based diff ──
function lcs(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({length: m+1}, () => new Uint16Array(n+1));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1]+1 : Math.max(dp[i-1][j], dp[i][j-1]);
const seqA = [], seqB = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (a[i-1] === b[j-1]) { seqA.unshift(i-1); seqB.unshift(j-1); i--; j--; }
else if (dp[i-1][j] > dp[i][j-1]) i--;
else j--;
}
return { seqA, seqB };
}
function buildDiff(linesA, linesB) {
const { seqA, seqB } = lcs(linesA, linesB);
const matchA = new Set(seqA);
const matchB = new Set(seqB);
const resultA = linesA.map((line, i) => ({ line, status: matchA.has(i) ? 'match' : 'changed' }));
const resultB = linesB.map((line, i) => ({ line, status: matchB.has(i) ? 'match' : 'changed' }));
return { resultA, resultB };
}
// ── Split into pages ──
// We try to break at scene headings where possible near the page boundary
function paginateResult(result) {
const pages = [];
let i = 0;
while (i < result.length) {
let end = Math.min(i + LINES_PER_PAGE, result.length);
// try to break at a scene heading just before the hard limit
if (end < result.length) {
let breakAt = -1;
for (let k = end - 1; k >= i + Math.floor(LINES_PER_PAGE * 0.6); k--) {
if (isSceneHeading(result[k].line)) { breakAt = k; break; }
}
if (breakAt > i) end = breakAt;
}
pages.push(result.slice(i, end));
i = end;
}
return pages;
}
// ── Render a page into a column ──
function renderPage(colEl, pageItems) {
colEl.innerHTML = '';
if (!pageItems || pageItems.length === 0) {
colEl.innerHTML = '<div class="page-empty"><div class="page-empty-icon">β€”</div><div>No content on this page</div></div>';
return;
}
const wrap = document.createElement('div');
wrap.className = 'page-content';
pageItems.forEach((item, idx) => {
const div = document.createElement('div');
const scene = isSceneHeading(item.line);
div.className = 'block ' + (scene ? 'scene-heading' : item.status);
div.dataset.status = scene ? 'scene' : item.status;
div.textContent = item.line;
// apply current filter
if (currentFilter !== 'all') {
const st = div.dataset.status;
if (currentFilter === 'changed' && st !== 'changed' && st !== 'scene') div.style.display = 'none';
if (currentFilter === 'match' && st !== 'match' && st !== 'scene') div.style.display = 'none';
}
wrap.appendChild(div);
});
colEl.appendChild(wrap);
colEl.scrollTop = 0;
}
// ── Update UI indicators ──
function updateUI() {
const totalLeft = pagesLeft.length;
const totalRight = pagesRight.length;
document.getElementById('left-page-ind').textContent = `Pg ${pageLeft+1} / ${totalLeft}`;
document.getElementById('right-page-ind').textContent = `Pg ${pageRight+1} / ${totalRight}`;
document.getElementById('left-prev').disabled = pageLeft === 0;
document.getElementById('left-next').disabled = pageLeft >= totalLeft - 1;
document.getElementById('right-prev').disabled = pageRight === 0;
document.getElementById('right-next').disabled = pageRight >= totalRight - 1;
// bottom bar shows synced page or "out of sync"
if (synced) {
document.getElementById('page-indicator').textContent = `PAGE ${pageLeft+1} / ${Math.max(totalLeft, totalRight)}`;
} else {
document.getElementById('page-indicator').textContent = `L:${pageLeft+1}/${totalLeft} R:${pageRight+1}/${totalRight}`;
}
document.getElementById('btn-prev').disabled = synced ? pageLeft === 0 : (pageLeft === 0 && pageRight === 0);
document.getElementById('btn-next').disabled = synced
? pageLeft >= Math.min(totalLeft, totalRight) - 1
: (pageLeft >= totalLeft - 1 && pageRight >= totalRight - 1);
}
function renderBoth() {
renderPage(document.getElementById('col-left'), pagesLeft[pageLeft]);
renderPage(document.getElementById('col-right'), pagesRight[pageRight]);
updateUI();
}
// ── Synced navigation (bottom bar) ──
function goPage(dir) {
if (synced) {
const newPage = pageLeft + dir;
if (newPage < 0 || newPage >= Math.min(pagesLeft.length, pagesRight.length)) return;
pageLeft = newPage;
pageRight = newPage;
} else {
// move both independently only if possible
if (dir === -1) {
if (pageLeft > 0) pageLeft--;
if (pageRight > 0) pageRight--;
} else {
if (pageLeft < pagesLeft.length - 1) pageLeft++;
if (pageRight < pagesRight.length - 1) pageRight++;
}
}
renderBoth();
}
// ── Independent side navigation ──
function goPageSide(side, dir) {
if (side === 'left') {
const np = pageLeft + dir;
if (np < 0 || np >= pagesLeft.length) return;
pageLeft = np;
renderPage(document.getElementById('col-left'), pagesLeft[pageLeft]);
// if pages are now out of sync, auto-unsync
if (synced && pageLeft !== pageRight) {
// we still allow it β€” just update the indicator
}
} else {
const np = pageRight + dir;
if (np < 0 || np >= pagesRight.length) return;
pageRight = np;
renderPage(document.getElementById('col-right'), pagesRight[pageRight]);
}
updateUI();
}
// ── Sync toggle ──
function toggleSync() {
synced = !synced;
const btn = document.getElementById('sync-toggle');
if (synced) {
// re-align right to left
pageRight = Math.min(pageLeft, pagesRight.length - 1);
btn.textContent = 'β‡… PAGES SYNCED';
btn.className = 'sync-status synced';
renderBoth();
} else {
btn.textContent = 'β‡… INDEPENDENT';
btn.className = 'sync-status unsynced';
updateUI();
}
}
// ── Stats ──
function computeStats(r1, r2) {
const scenes1 = r1.filter(x => isSceneHeading(x.line)).length;
const scenes2 = r2.filter(x => isSceneHeading(x.line)).length;
const matched1 = r1.filter(x => x.status === 'match').length;
const pct = Math.round((matched1 / Math.max(r1.length, 1)) * 100);
document.getElementById('st-scenes-1').textContent = scenes1;
document.getElementById('st-scenes-2').textContent = scenes2;
document.getElementById('st-scenes-cut').textContent = Math.max(0, scenes1 - scenes2);
document.getElementById('st-lines-1').textContent = r1.length;
document.getElementById('st-lines-2').textContent = r2.length;
document.getElementById('st-lines-cut').textContent = Math.max(0, r1.length - r2.length);
document.getElementById('st-match-pct').textContent = pct + '%';
document.getElementById('st-pages').textContent = Math.max(pagesLeft.length, pagesRight.length);
}
// ── Scene jump ──
function buildSceneJump(result) {
const sel = document.getElementById('scene-jump');
sel.innerHTML = '<option value="">β€” Scene β€”</option>';
let pageIdx = 0;
let lineCount = 0;
result.forEach((item, idx) => {
if (isSceneHeading(item.line)) {
// figure out which page this scene is on
let pg = 0, cumulative = 0;
for (let p = 0; p < pagesLeft.length; p++) {
if (cumulative + pagesLeft[p].length > idx) { pg = p; break; }
cumulative += pagesLeft[p].length;
}
const opt = document.createElement('option');
opt.value = pg;
opt.textContent = `Pg ${pg+1} β€” ${item.line.slice(0, 45)}`;
sel.appendChild(opt);
}
});
}
function jumpToScene(pgStr) {
if (pgStr === '') return;
const pg = parseInt(pgStr);
pageLeft = Math.min(pg, pagesLeft.length - 1);
pageRight = synced ? Math.min(pg, pagesRight.length - 1) : pageRight;
renderBoth();
}
// ── Filter ──
function setFilter(mode, btn) {
currentFilter = mode;
document.querySelectorAll('.tb-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// re-render current pages with filter
renderBoth();
}
// ── Run ──
async function runComparison() {
document.getElementById('loader').classList.add('visible');
try {
const [linesA, linesB] = await Promise.all([extractLines(files[1]), extractLines(files[2])]);
const { resultA, resultB } = buildDiff(linesA, linesB);
pagesLeft = paginateResult(resultA);
pagesRight = paginateResult(resultB);
pageLeft = 0;
pageRight = 0;
computeStats(resultA, resultB);
buildSceneJump(resultA);
document.getElementById('lbl1').textContent = files[1].name;
document.getElementById('lbl2').textContent = files[2].name;
document.getElementById('upload-view').style.display = 'none';
document.getElementById('compare-view').classList.add('visible');
document.getElementById('stats-bar').classList.add('visible');
document.getElementById('toolbar').classList.add('visible');
document.getElementById('page-bar').classList.add('visible');
renderBoth();
} catch(err) {
alert('Error reading files: ' + err.message);
}
document.getElementById('loader').classList.remove('visible');
}
// ── Keyboard navigation ──
document.addEventListener('keydown', e => {
if (!document.getElementById('compare-view').classList.contains('visible')) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); goPage(+1); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); goPage(-1); }
});
// ── Reset ──
function resetTool() {
files[1] = null; files[2] = null;
['1','2'].forEach(n => {
document.getElementById('card'+n).classList.remove('loaded','drag-over');
document.getElementById('name'+n).textContent = 'Drop .docx here or click';
document.getElementById('file'+n).value = '';
});
document.getElementById('compare-btn').classList.remove('visible');
document.getElementById('upload-view').style.display = '';
document.getElementById('compare-view').classList.remove('visible');
document.getElementById('stats-bar').classList.remove('visible');
document.getElementById('toolbar').classList.remove('visible');
document.getElementById('page-bar').classList.remove('visible');
document.getElementById('col-left').innerHTML = '';
document.getElementById('col-right').innerHTML = '';
pagesLeft = []; pagesRight = [];
pageLeft = 0; pageRight = 0;
synced = true; currentFilter = 'all';
document.getElementById('sync-toggle').textContent = 'β‡… PAGES SYNCED';
document.getElementById('sync-toggle').className = 'sync-status synced';
}
</script>
</body>
</html>