Download index.html from abeea/testts: direct link, hf CLI and curl.
- Browser
- Download file 30.8 kB
-
https://huggingface.co/spaces/abeea/testts/resolve/main/index.html
- Command line
-
hf download hf://spaces/abeea/testts/index.html
-
curl -L -o index.html https://huggingface.co/spaces/abeea/testts/resolve/main/index.html
30.8 kB
| <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> | |