| <!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 { |
| 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 { |
| 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-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 { |
| display: none; |
| flex: 1; |
| overflow: hidden; |
| flex-direction: column; |
| } |
| #compare-view.visible { display: flex; } |
| |
| |
| .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; } |
| |
| |
| .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-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); } |
| |
| |
| .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; } |
| |
| |
| .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); } |
| |
| |
| .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; } |
| |
| |
| @keyframes pageFadeIn { |
| from { opacity: 0; transform: translateY(6px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .page-content { animation: pageFadeIn .18s ease; } |
| |
| |
| #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); } |
| |
| |
| #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); } |
| |
| |
| #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> |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div id="compare-view"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div id="loader"> |
| <div class="loader-text">Analyzing Scriptβ¦</div> |
| <div class="loader-bar"><div class="loader-fill"></div></div> |
| </div> |
|
|
| <script> |
| |
| const files = { 1: null, 2: null }; |
| let pagesLeft = []; |
| let pagesRight = []; |
| let pageLeft = 0; |
| let pageRight = 0; |
| let synced = true; |
| let currentFilter = 'all'; |
| |
| const LINES_PER_PAGE = 40; |
| |
| |
| 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'); |
| } |
| |
| |
| 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); |
| }); |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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 }; |
| } |
| |
| |
| |
| function paginateResult(result) { |
| const pages = []; |
| let i = 0; |
| while (i < result.length) { |
| let end = Math.min(i + LINES_PER_PAGE, result.length); |
| |
| 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; |
| } |
| |
| |
| 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; |
| |
| 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; |
| } |
| |
| |
| 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; |
| |
| |
| 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(); |
| } |
| |
| |
| 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 { |
| |
| 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(); |
| } |
| |
| |
| 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 (synced && pageLeft !== pageRight) { |
| |
| } |
| } else { |
| const np = pageRight + dir; |
| if (np < 0 || np >= pagesRight.length) return; |
| pageRight = np; |
| renderPage(document.getElementById('col-right'), pagesRight[pageRight]); |
| } |
| updateUI(); |
| } |
| |
| |
| function toggleSync() { |
| synced = !synced; |
| const btn = document.getElementById('sync-toggle'); |
| if (synced) { |
| |
| 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(); |
| } |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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)) { |
| |
| 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(); |
| } |
| |
| |
| function setFilter(mode, btn) { |
| currentFilter = mode; |
| document.querySelectorAll('.tb-btn').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| |
| renderBoth(); |
| } |
| |
| |
| 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'); |
| } |
| |
| |
| 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); } |
| }); |
| |
| |
| 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> |
|
|