From db1b833c8cc12f291058b61000fd11adf138518b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 14:53:27 +0000 Subject: [PATCH 01/45] Split src/template.html into styles.css and ordered src/js modules The single 7,000-line template is now a page shell plus src/styles.css and ten src/js/*.js files that build.py concatenates in file-name order into the same single inline @@ -3500,344 +3501,344 @@ diff --git a/README.md b/README.md index 3e582fc..40b382a 100644 --- a/README.md +++ b/README.md @@ -103,14 +103,15 @@ Download **[`DocDiff.html`](https://github.com/tschnorpfeil/docdiff/raw/main/Doc ## Building from Source -Edit [`src/template.html`](src/template.html) and run the build and verification scripts: +Edit the sources in [`src/`](src) and run the build and verification scripts: ```sh python3 scripts/build.py node scripts/check.mjs ``` -- `scripts/build.py` bundles `src/template.html` with the pinned libraries and fonts in `vendor/` to produce `site/index.html` and `DocDiff.html`. +- `src/template.html` holds the page shell and markup, `src/styles.css` the styles, and `src/js/*.js` the app code (concatenated in file-name order). +- `scripts/build.py` bundles them with the pinned libraries and fonts in `vendor/` to produce `site/index.html` and `DocDiff.html`. - `scripts/check.mjs` verifies CSP headers, zero external URLs, zero storage APIs, and 1:1 `de`/`en` translation key parity. --- diff --git a/scripts/build.py b/scripts/build.py index 615d67c..6ea0305 100755 --- a/scripts/build.py +++ b/scripts/build.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Builds the self-contained single-file HTML bundle (`site/index.html` and `DocDiff.html`) -from `src/template.html` and `vendor/`. +from `src/template.html`, `src/styles.css`, `src/js/*.js` and `vendor/`. """ import base64 import os @@ -11,6 +11,8 @@ FONT_DIR = os.path.join(ROOT, "vendor", "fonts") LIB_DIR = os.path.join(ROOT, "vendor", "libs") TEMPLATE_PATH = os.path.join(ROOT, "src", "template.html") +CSS_PATH = os.path.join(ROOT, "src", "styles.css") +JS_DIR = os.path.join(ROOT, "src", "js") def b64_file(path: str) -> str: @@ -23,8 +25,17 @@ def read_text(path: str) -> str: return f.read() +def app_js_files() -> list: + """App scripts, concatenated in file-name order into one classic '); -const appScript = templateHtml.slice(lastScriptIdx); -const appJsCode = templateHtml.slice(lastScriptIdx + ' @@ -3500,344 +3501,344 @@ diff --git a/src/js/01-i18n.js b/src/js/01-i18n.js new file mode 100644 index 0000000..531570e --- /dev/null +++ b/src/js/01-i18n.js @@ -0,0 +1,334 @@ +// PDF.js worker is inlined via globalThis.pdfjsWorker (100% offline & file:// compatible) + +const I18N = { + de: { + changesLabel: 'Änderungen', + statTooltip: 'Gesamtzahl geänderter Absätze · Grün = Neu / Geändert · Rot = Entfernt / Geändert', + modePages: 'Seiten', + modePagesTitle: 'Original Seiten-Layout: Dokumentseiten als einzelne Blätter nebeneinander darstellen', + modeSplit: 'Textfluss', + modeSplitTitle: 'Endloser Textfluss: Beide Dokumente ohne Seitenumbrüche nebeneinander darstellen', + modeInline: 'Kompakt', + modeInlineTitle: '1-Spalten-Leseansicht: Alle Änderungen kombiniert im Seiten-Layout', + hfFilterBtn: 'Ohne Kopf-/Fußzeile', + hfFilterTitle: 'Kopf- und Fußzeilen sowie Seitenzahlen automatisch aus dem Diff herausfiltern (Taste H)', + pageLabel: 'Seite', + pageShort: 'S.', + pageOf: 'von', + pageUnchanged: 'Unverändert', + typeHf: 'Kopf-/Fußzeile', + searchPlaceholder: 'Suchen…', + searchTitle: 'Beide Dokumente durchsuchen (Taste /)', + prevTitle: 'Vorherige Änderung anspringen (Taste K)', + nextTitle: 'Nächste Änderung anspringen (Taste J)', + onlyChanges: 'Nur Änderungen', + onlyChangesTitle: 'Unveränderte Absätze ausblenden und nur Abweichungen zeigen (Taste C)', + syncBtn: 'Sync-Scroll', + syncTitle: 'Beide Spalten beim Scrollen automatisch parallel mitbewegen', + textBtn: 'Text einfügen', + textBtnTitle: 'Texte für Vorher & Nachher direkt per Copy & Paste eingeben', + exportBtn: 'Speichern / Export', + exportTitle: 'Neuen Stand (Rechts) kopieren oder als Datei speichern', + expCopy: 'Neuen Stand kopieren', + expTxt: 'Neuen Stand speichern', + copiedLabel: 'Kopiert', + expPdf: 'PDF / Druckansicht', + expWord: 'Word-Redline (mit Änderungen)', + expHtml: 'HTML-Vergleichsbericht', + expMd: 'Markdown-Bericht', + editBtn: 'Bearbeiten', + editInsertBtn: 'Einfügen', + editTitle: 'Diesen Absatz im neuen Stand direkt bearbeiten (oder Doppelklick)', + revertBtn: 'Vorher übernehmen', + revertTitle: 'Änderung verwerfen und den alten Stand (Vorher) übernehmen (⌘Z für Rückgängig)', + editHint: 'Doppelklick oder „Bearbeiten“ klicken, um diesen Absatz im neuen Stand anzupassen', + editorHintText: 'Enter = Übernehmen · Esc = Abbrechen · ⌘Z = Rückgängig', + editorSave: 'Übernehmen', + editorCancel: 'Abbrechen', + badgeEditable: 'Bearbeitbar', + badgeEditableTitle: 'Rechts ist das Zieldokument: Absätze per Klick auf „Bearbeiten“ oder Doppelklick direkt anpassen', + clearBtn: 'Zurücksetzen', + clearBtnTitle: 'Beide Dokumente schließen und Arbeitsspeicher leeren', + helpBtn: 'Anleitung', + helpBtnTitle: 'Kurzanleitung, Farblegende & Tastenkürzel öffnen', + langTitle: 'Sprache wechseln (Deutsch / English)', + themeTitle: 'Darstellung wechseln (Hell / Dunkel · Standard: System)', + sidebarToggleTitle: 'Änderungsliste rechts ein-/ausblenden', + mobFilter: 'Nur Änd.', + mobList: 'Liste', + mobExport: 'Export', + mobDone: 'Fertig', + colLeft: '1 · Vorher', + colLeftTitle: 'Ausgangsdokument (Alte Fassung · schreibgeschützte Referenz)', + colRight: '2 · Nachher', + colRightTitle: 'Zieldokument (Neue Fassung · direkt bearbeitbar & speicherbar)', + noFileLeft: 'Kein Ausgangsdokument', + noFileRight: 'Kein neues Dokument', + noFile: 'Keine Datei geladen', + fileBtn: 'Datei laden', + fileLeftTitle: 'Ausgangsdokument (Vorher) als PDF, Word (.docx) oder Text laden', + fileRightTitle: 'Neues Dokument (Nachher) als PDF, Word (.docx) oder Text laden', + clearSideTitle: 'Dieses Dokument entfernen', + swapTitle: 'Vorher (Links) und Nachher (Rechts) miteinander tauschen', + sidebarTitle: 'Änderungen', + dropLeft: '1 · Vorher (Alter Stand) hier ablegen (PDF / DOCX / TXT)', + dropRight: '2 · Nachher (Neuer Stand) hier ablegen (PDF / DOCX / TXT)', + lpTitle: 'Zwei Dokumentstände vergleichen', + lpSub: 'Wählen Sie links die alte Fassung (Vorher) und rechts den neuen Stand (Nachher) — oder ziehen Sie beide Dateien direkt hierher.', + lpLeftTitle: 'Ausgangsdokument (Vorher)', + lpLeftHint: 'PDF, Word (.docx) oder Text', + lpRightTitle: 'Überarbeiteter Stand (Nachher)', + lpRightHint: 'Abweichungen werden sofort markiert', + lpLoaded: 'Geladen', + lpChangeFile: 'Andere Datei wählen…', + lpStep1: 'Vorher & Nachher wählen', + lpStep2: 'Abweichungen prüfen', + lpStep3: 'Rechts anpassen & speichern', + lpTrust: '100 % lokal im Browser (kein Upload)', + stepLeftBadge: 'Schritt 1 · Vorher (Alter Stand)', + stepRightBadge: 'Schritt 2 · Nachher (Neuer Stand)', + emptyLeftTitle: 'Ausgangsdokument (Vorher) laden', + emptyRightTitle: 'Neuen Stand (Nachher) laden', + emptyLeftDesc: 'Laden Sie hier die ursprüngliche Fassung (z. B. Vertrag v1 oder Vorversion) als PDF, Word (.docx) oder Text.', + emptyRightDesc: 'Laden Sie hier die überarbeitete Fassung (z. B. Gegenentwurf oder v2). Abweichungen werden sofort markiert.', + chooseFileLeft: 'Datei wählen (Vorher)', + chooseFileRight: 'Datei wählen (Nachher)', + pasteText: 'Text direkt einfügen', + loadSample: 'Beispiel-Vergleich starten (Vertrag Vorher vs. Nachher)', + guideLeftHead: 'So funktioniert Doc Diff in 3 Schritten', + guideStep1: 'Links (Vorher): Alte Fassung als schreibgeschützte Referenz laden.', + guideStep2: 'Rechts (Nachher): Neue Fassung zum Abgleich gegenüberstellen.', + guideStep3: 'Rechts anpassen & speichern: Im neuen Stand Absätze per Bearbeiten oder Vorher übernehmen anpassen und oben rechts unter Speichern / Export herunterladen.', + guideRightHead: 'Farblegende & Bearbeitung im neuen Stand (Rechts)', + guideLegendDel: 'Rot durchgestrichen = Im neuen Stand entfernt', + guideLegendIns: 'Grün markiert = Im neuen Stand neu hinzugefügt', + guideLegendEdit: 'Bearbeiten & Vorher übernehmen = Direkt hier im rechten Dokument anpassbar', + sidebarEmptyTitle: 'Noch kein Vergleich aktiv', + sidebarEmptyDesc: 'Sobald links (Vorher) und rechts (Nachher) geladen sind, erscheinen hier alle geänderten Absätze zum schnellen Anspringen.', + noMatchesTitle: 'Keine Treffer', + noMatchesDesc: 'Keine Änderung entspricht dem Suchbegriff.', + identicalTitle: 'Dokumente sind identisch', + identicalDesc: 'Zwischen Vorher (Links) und Nachher (Rechts) wurden keine Textabweichungen festgestellt.', + typeMod: 'Geändert', + typeIns: 'Neu', + typeDel: 'Entfernt', + filterAll: 'Alle', + filterMod: 'Geändert', + filterIns: 'Neu', + filterDel: 'Entfernt', + moreDeltas: 'weitere Änderungen', + undoTitle: 'Letzte Bearbeitung rückgängig machen (⌘Z / Strg+Z)', + redoTitle: 'Wiederherstellen (⇧⌘Z / Strg+Y)', + stepCountTitle: 'Aktuelle Änderung · Klicken oder J / K zum Springen zwischen Abweichungen', + sidebarKbdTitle: 'Tastenkürzel: J = Nächste Änderung · K = Vorherige Änderung', + unitPara: 'Abs.', + readingFile: 'Lese', + readError: 'Fehler beim Lesen der Datei', + manualLeft: 'Manueller Text (Vorher)', + manualRight: 'Manueller Text (Nachher)', + modalPasteTitle: 'Texte direkt eingeben oder einfügen', + modalLeftLabel: '1 · Vorher — Alter Stand (Links, Referenz)', + modalRightLabel: '2 · Nachher — Neuer Stand (Rechts, bearbeitbar)', + modalLeftPh: 'Ursprünglichen Text (Vorher) hier einfügen…', + modalRightPh: 'Neuen / überarbeiteten Text (Nachher) hier einfügen…', + cancel: 'Abbrechen', + compare: 'Jetzt vergleichen', + helpTitle: 'Kurzanleitung — So funktioniert Doc Diff', + helpSampleBtn: 'Beispiel laden', + helpOkBtn: 'Verstanden', + helpBodyHtml: ` +
+
+
1 · Vorher vs. 2 · Nachher
+
Links (Vorher) liegt Ihr unverändertes Ausgangsdokument als Referenz. Rechts (Nachher) liegt der neue Stand, den Sie prüfen, anpassen und speichern.
+
+
+
Seiten-Layout & Kopf-/Fußzeilen-Filter
+
In der Ansicht Seiten bleiben originale Seitenumbrüche, Überschriften, Unterkapitel und Aufzählungen erhalten. Wiederkehrende Kopf-/Fußzeilen und Seitenzahlen werden automatisch aus dem Diff gefiltert (unten per Ohne Kopf-/Fußzeile umschaltbar).
+
+
+
Direkt bearbeiten & speichern
+
Fahren Sie rechts über einen Absatz: Mit Bearbeiten (oder Doppelklick) passen Sie den Text an, mit Vorher übernehmen setzen Sie ihn zurück. Über Speichern / Export laden Sie das Ergebnis herunter.
+
+
+
Tastenkürzel & 100 % Lokaler Schutz
+
J / K Nächste/Vorherige Änderung · / Suche · C Nur Änderungen · H Kopf-/Fußzeilen · ⌘Z Rückgängig.
Alle Daten bleiben zu 100 % lokal im Browser (connect-src 'none').
+
+
+ `, + footerPrivacy: 'Impressum & Datenschutz', + privacyTitle: 'Impressum & Datenschutz', + privacyBodyHtml: ` +

Impressum (§ 5 DDG):
Tobias Schnorpfeil · Im Palert 5 · 56253 Treis-Karden · tschnorpfeil@gmail.com

+

Datenschutz:
Alle Dateien und Texte werden ausschließlich lokal im Arbeitsspeicher Ihres Browsers verarbeitet. Es werden keine Dokumente hochgeladen, keine Cookies gesetzt und keine Tracker genutzt. Beim Aufruf über docdiff.app werden technisch notwendige Verbindungsdaten durch GitHub Pages (GitHub, Inc., USA) und Cloudflare DNS verarbeitet (Art. 6 Abs. 1 lit. f DSGVO).

+

Impressum · Datenschutz · GitHub

+ `, + sampleLeftName: 'Vertrag_v1_Vorher.docx', + sampleRightName: 'Vertrag_v2_Nachher.docx', + }, + en: { + changesLabel: 'changes', + statTooltip: 'Total changed paragraphs · Green = Added / Modified · Red = Removed / Modified', + modePages: 'Pages', + modePagesTitle: 'Original page layout: Render document pages as distinct paper sheets side-by-side', + modeSplit: 'Stream', + modeSplitTitle: 'Continuous stream: Show both documents side-by-side without page breaks', + modeInline: 'Unified', + modeInlineTitle: '1-column reading view: All changes combined inline with page layout', + hfFilterBtn: 'No Header/Footer', + hfFilterTitle: 'Automatically filter running headers, footers, and page numbers from the diff (Key H)', + pageLabel: 'Page', + pageShort: 'p.', + pageOf: 'of', + pageUnchanged: 'Unchanged', + typeHf: 'Header/Footer', + searchPlaceholder: 'Search…', + searchTitle: 'Search across both documents (Key /)', + prevTitle: 'Jump to previous change (Key K)', + nextTitle: 'Jump to next change (Key J)', + onlyChanges: 'Changes only', + onlyChangesTitle: 'Hide unchanged paragraphs and show differences only (Key C)', + syncBtn: 'Sync-Scroll', + syncTitle: 'Scroll both document columns together automatically', + textBtn: 'Paste text', + textBtnTitle: 'Paste or type text for Before & After directly', + exportBtn: 'Save / Export', + exportTitle: 'Copy the new version (Right) or download as a file', + expCopy: 'Copy new version text', + expTxt: 'Save new version (.txt)', + copiedLabel: 'Copied', + expPdf: 'PDF / Print view', + expWord: 'Word Redline (with changes)', + expHtml: 'HTML Diff Report', + expMd: 'Markdown Report', + editBtn: 'Edit', + editInsertBtn: 'Insert', + editTitle: 'Edit this paragraph directly in the new version (or double-click)', + revertBtn: 'Use old', + revertTitle: 'Discard change and restore the old version (Before) (Cmd+Z to undo)', + editHint: 'Double-click or click "Edit" to modify this paragraph in the new version', + editorHintText: 'Enter = Apply · Esc = Cancel · Cmd+Z = Undo', + editorSave: 'Apply', + editorCancel: 'Cancel', + badgeEditable: 'Editable', + badgeEditableTitle: 'Right column is your target document: click "Edit" or double-click any paragraph to edit', + clearBtn: 'Reset', + clearBtnTitle: 'Close both documents and wipe memory', + helpBtn: 'Guide', + helpBtnTitle: 'Open quick guide, color legend & keyboard shortcuts', + langTitle: 'Switch language (Deutsch / English)', + themeTitle: 'Switch appearance (Light / Dark · Default: System)', + sidebarToggleTitle: 'Show / hide changes list on the right', + mobFilter: 'Changes', + mobList: 'List', + mobExport: 'Export', + mobDone: 'Done', + colLeft: '1 · Before', + colLeftTitle: 'Baseline document (Old version · read-only reference)', + colRight: '2 · After', + colRightTitle: 'Target document (New version · directly editable & exportable)', + noFileLeft: 'No baseline file', + noFileRight: 'No new file', + noFile: 'No file loaded', + fileBtn: 'Load file', + fileLeftTitle: 'Load baseline document (Before) as PDF, Word (.docx), or text', + fileRightTitle: 'Load new document (After) as PDF, Word (.docx), or text', + clearSideTitle: 'Remove this document', + swapTitle: 'Swap Before (Left) and After (Right) documents', + sidebarTitle: 'Changes', + dropLeft: '1 · Drop Before (Old version) here (PDF / DOCX / TXT)', + dropRight: '2 · Drop After (New version) here (PDF / DOCX / TXT)', + lpTitle: 'Compare two document versions', + lpSub: 'Select the baseline version (Before) on the left and the revised version (After) on the right — or drop both files right here.', + lpLeftTitle: 'Baseline document (Before)', + lpLeftHint: 'PDF, Word (.docx), or text', + lpRightTitle: 'Revised version (After)', + lpRightHint: 'Differences are highlighted instantly', + lpLoaded: 'Loaded', + lpChangeFile: 'Choose another file…', + lpStep1: 'Select Before & After', + lpStep2: 'Review differences', + lpStep3: 'Edit & export on the right', + lpTrust: '100% local in browser (no upload)', + stepLeftBadge: 'Step 1 · Before (Old Version)', + stepRightBadge: 'Step 2 · After (New Version)', + emptyLeftTitle: 'Load baseline document (Before)', + emptyRightTitle: 'Load new version (After)', + emptyLeftDesc: 'Drop the original version (e.g. Contract v1 or prior draft) here as PDF, Word (.docx), or text.', + emptyRightDesc: 'Drop the revised version (e.g. counter-proposal or v2) here. All differences are highlighted instantly.', + chooseFileLeft: 'Choose file (Before)', + chooseFileRight: 'Choose file (After)', + pasteText: 'Paste text directly', + loadSample: 'Load interactive sample (Contract Before vs. After)', + guideLeftHead: 'How Doc Diff works in 3 steps', + guideStep1: 'Left (Before): Load the old version as your read-only baseline.', + guideStep2: 'Right (After): Load the new version to compare against it.', + guideStep3: 'Edit & Save on the Right: Click Edit or Use old on any paragraph in the new version, then export via Save / Export at the top right.', + guideRightHead: 'Color legend & editing in the new version (Right)', + guideLegendDel: 'Red strikethrough = Removed in the new version', + guideLegendIns: 'Green highlight = Added in the new version', + guideLegendEdit: 'Edit & Use old = Directly adjustable in the right document', + sidebarEmptyTitle: 'No active comparison', + sidebarEmptyDesc: 'Once both Before (Left) and After (Right) are loaded, all changed clauses appear here for quick navigation.', + noMatchesTitle: 'No matches', + noMatchesDesc: 'No change matches your search query.', + identicalTitle: 'Documents are identical', + identicalDesc: 'No text differences were found between Before (Left) and After (Right).', + typeMod: 'Modified', + typeIns: 'Added', + typeDel: 'Removed', + filterAll: 'All', + filterMod: 'Modified', + filterIns: 'Added', + filterDel: 'Removed', + moreDeltas: 'more changes', + undoTitle: 'Undo last edit (Cmd+Z / Ctrl+Z)', + redoTitle: 'Redo (Shift+Cmd+Z / Ctrl+Y)', + stepCountTitle: 'Current change · Click or press J / K to jump between differences', + sidebarKbdTitle: 'Shortcuts: J = Next change · K = Previous change', + unitPara: 'paras', + readingFile: 'Reading', + readError: 'Failed to read file', + manualLeft: 'Pasted text (Before)', + manualRight: 'Pasted text (After)', + modalPasteTitle: 'Type or paste texts directly', + modalLeftLabel: '1 · Before — Old Version (Left, Reference)', + modalRightLabel: '2 · After — New Version (Right, Editable)', + modalLeftPh: 'Paste baseline text (Before) here…', + modalRightPh: 'Paste new / revised text (After) here…', + cancel: 'Cancel', + compare: 'Compare now', + helpTitle: 'Quick Guide — How Doc Diff Works', + helpSampleBtn: 'Load sample', + helpOkBtn: 'Got it', + helpBodyHtml: ` +
+
+
1 · Before vs. 2 · After
+
Left (Before) holds your unchanged baseline document as a reference. Right (After) holds the new version that you review, edit, and export.
+
+
+
Page Layout & Header/Footer Filter
+
In Pages view, original page breaks, headings, sub-headings, and bullet lists are preserved. Repeating headers, footers, and page numbers are automatically filtered out (toggle via No Header/Footer in the bottom dock).
+
+
+
Edit directly & Export
+
Hover any paragraph on the right: click Edit (or double-click) to rewrite it, or Use old to revert. Use Save / Export to copy or download the result.
+
+
+
Shortcuts & 100% Local Privacy
+
J / K Next/Previous change · / Search · C Changes only · H Headers/Footers · Cmd+Z Undo.
All data stays 100% in your browser's volatile memory (connect-src 'none').
+
+
+ `, + footerPrivacy: 'Legal & Privacy', + privacyTitle: 'Legal Notice & Privacy', + privacyBodyHtml: ` +

Legal Notice (§ 5 DDG):
Tobias Schnorpfeil · Im Palert 5 · 56253 Treis-Karden, Germany · tschnorpfeil@gmail.com

+

Privacy:
All files and texts are processed strictly locally in your browser's memory. No documents are uploaded, no cookies are set, and no analytics are used. When accessed via docdiff.app, standard connection metadata (IP address, timestamp, user agent) is processed by GitHub Pages (GitHub, Inc., USA) and Cloudflare DNS (Art. 6(1)(f) GDPR).

+

Impressum · Privacy Policy · GitHub

+ `, + sampleLeftName: 'Contract_v1_Before.docx', + sampleRightName: 'Contract_v2_After.docx', + }, +}; diff --git a/src/js/02-samples.js b/src/js/02-samples.js new file mode 100644 index 0000000..270c290 --- /dev/null +++ b/src/js/02-samples.js @@ -0,0 +1,281 @@ +const SAMPLES = { + de: { + v1: `=== [SEITE 1] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Fassung v1.0 (Stand: 12.03.2026) · Seite 1 von 3 + +RAHMENVERTRAG ÜBER IT- UND PLANUNGSLEISTUNGEN + +Zwischen der BauTech Projektentwicklung GmbH, Friedrichstraße 114, 10117 Berlin (nachfolgend „Auftraggeber“ genannt) und der Alpha Systems Engineering AG, Maximilianstraße 35, 80539 München (nachfolgend „Auftragnehmer“ genannt). + +§ 1 Vertragsgegenstand und Leistungsumfang + +(1) Der Auftragnehmer verpflichtet sich zur schlüsselfertigen Erstellung, Konfiguration und Dokumentation der Cloud-Kalkulationsplattform gemäß dem Lastenheft vom 12.03.2026 (Anlage 1). + +1.1 Kernmodule und Liefergegenstände + +• Modul A: Mengenermittlung und strukturierter LV-Import (GAEB DA XML 3.3) +• Modul B: Urkalkulation, EKT-Aufgliederung und Zuschlagsberechnung (AGK / WuG) +• Modul C: Standard-Berichtswesen und Preisspiegel-Export als PDF-Dokument + +(2) Nachträgliche Änderungswünsche des Auftraggebers (Change Requests) sind schriftlich einzureichen und werden auf Basis der vereinbarten Tagessätze gesondert vergütet, sofern sie den ursprünglichen Aufwand um mehr als 5,0 % überschreiten. + +(3) Der Auftragnehmer erbringt sämtliche Leistungen ausschließlich durch eigenes, festangestelltes Fachpersonal am Standort Deutschland. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 1 von 3 +=== [SEITE 2] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Fassung v1.0 (Stand: 12.03.2026) · Seite 2 von 3 + +§ 2 Vergütung, Urkalkulation und Zahlungsbedingungen + +(1) Für die in § 1 genannten Leistungen vereinbaren die Parteien einen Pauschalfestpreis in Höhe von 145.000,00 € netto zuzüglich der gesetzlichen Umsatzsteuer. + +2.1 Kalkulationsgrundlagen und Zuschläge + +(2) Der Auftragnehmer hinterlegt vor Vertragsbeginn eine verschlossene Urkalkulation beim Auftraggeber, in der sämtliche Einzelkosten der Teilleistungen (EKT) sowie der kalkulierte Zuschlag für Allgemeine Geschäftskosten (AGK) in Höhe von 12,0 % und Wagnis und Gewinn (WuG) in Höhe von 6,0 % offengelegt sind. + +(3) Abschlagsrechnungen sind innerhalb von 14 Kalendertagen nach Rechnungseingang ohne Abzug zur Zahlung fällig. Bei Zahlung innerhalb von 7 Kalendertagen gewährt der Auftragnehmer 2,0 % Skonto. + +§ 3 Termine und Vertragsstrafe bei Verzug + +(1) Die produktive Bereitstellung (Go-Live) der Gesamtlösung erfolgt spätestens am 30.09.2026. + +(2) Gerät der Auftragnehmer mit dem verbindlichen Fertigstellungstermin in Verzug, verwirkt er für jede vollendete Kalenderwoche des Verzugs eine Vertragsstrafe in Höhe von 0,5 % der Netto-Auftragssumme, maximal jedoch 5,0 % der Gesamtauftragssumme. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 2 von 3 +=== [SEITE 3] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Fassung v1.0 (Stand: 12.03.2026) · Seite 3 von 3 + +§ 4 Gewährleistung und Haftung + +(1) Die Verjährungsfrist für Mängelansprüche beträgt 24 Monate ab schriftlicher Endabnahme durch den Auftraggeber. + +(2) Die Haftung der Parteien für leichte Fahrlässigkeit ist bei Verletzung wesentlicher Vertragspflichten (Kardinalpflichten) auf den vertragstypischen, vorhersehbaren Schaden, höchstens jedoch auf 500.000,00 € je Schadensfall begrenzt. + +(3) Eine Haftung für entgangenen Gewinn oder mittelbare Folgeschäden ist im Falle leichter Fahrlässigkeit ausgeschlossen. + +§ 5 Geheimhaltung und Datenschutz + +(1) Beide Vertragsparteien verpflichten sich, alle im Rahmen der Zusammenarbeit erlangten vertraulichen Informationen sowie Geschäfts- und Betriebsgeheimnisse zeitlich unbegrenzt vertraulich zu behandeln. + +(2) Die Verarbeitung personenbezogener Daten erfolgt ausschließlich auf Servern innerhalb der Europäischen Union gemäß dem beigefügten Auftragsverarbeitungsvertrag (Anlage 2). + +§ 6 Kündigung und Schlussbestimmungen + +(1) Dieser Vertrag kann von beiden Parteien mit einer Frist von 3 Monaten zum Quartalsende ordentlich gekündigt werden. + +(2) Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Ausschließlicher Gerichtsstand für alle Streitigkeiten aus diesem Vertrag ist Berlin. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 3 von 3`, + v2: `=== [SEITE 1] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Gegenentwurf v2.0 (Stand: 18.04.2026) · Seite 1 von 4 + +RAHMENVERTRAG ÜBER IT- UND PLANUNGSLEISTUNGEN + +Zwischen der BauTech Projektentwicklung GmbH, Friedrichstraße 114, 10117 Berlin (nachfolgend „Auftraggeber“ genannt) und der Alpha Systems Engineering AG, Maximilianstraße 35, 80539 München (nachfolgend „Auftragnehmer“ genannt). + +§ 1 Vertragsgegenstand und Leistungsumfang + +(1) Der Auftragnehmer verpflichtet sich zur schlüsselfertigen Erstellung, Konfiguration, Schulung und Dokumentation der Cloud-Kalkulationsplattform gemäß dem aktualisierten Lastenheft vom 18.04.2026 (Anlage 1). + +1.1 Kernmodule und Liefergegenstände + +• Modul A: Mengenermittlung und strukturierter LV-Import (GAEB DA XML 3.3 & BIM-IFC) +• Modul B: Urkalkulation, EKT-Aufgliederung und Zuschlagsberechnung (AGK / WuG) +• Modul C: Echtzeit-Nachtragsmanagement, Preisspiegel und automatisierter Soll-Ist-Abgleich + +(2) Nachträgliche Änderungswünsche des Auftraggebers (Change Requests) sind in Textform einzureichen und werden auf Basis der vereinbarten Tagessätze gesondert vergütet, sofern sie den ursprünglichen Aufwand um mehr als 10,0 % überschreiten. + +(3) Der Auftragnehmer ist berechtigt, nach vorheriger schriftlicher Zustimmung des Auftraggebers qualifizierte Nachunternehmer innerhalb der Europäischen Union einzusetzen. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 1 von 4 +=== [SEITE 2] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Gegenentwurf v2.0 (Stand: 18.04.2026) · Seite 2 von 4 + +§ 2 Vergütung, Urkalkulation und Zahlungsbedingungen + +(1) Für die in § 1 genannten Leistungen vereinbaren die Parteien einen Pauschalfestpreis in Höhe von 168.500,00 € netto zuzüglich der gesetzlichen Umsatzsteuer. + +2.1 Kalkulationsgrundlagen und Zuschläge + +(2) Der Auftragnehmer hinterlegt vor Vertragsbeginn eine verschlossene Urkalkulation beim Auftraggeber, in der sämtliche Einzelkosten der Teilleistungen (EKT) sowie der kalkulierte Zuschlag für Allgemeine Geschäftskosten (AGK) in Höhe von 14,5 % und Wagnis und Gewinn (WuG) in Höhe von 7,5 % offengelegt sind. + +(3) Abschlagsrechnungen sind innerhalb von 30 Kalendertagen nach Rechnungseingang ohne Abzug zur Zahlung fällig. Ein Skontoabzug wird nicht gewährt. + +(4) NEU: Preisanpassungsklausel — Bei einer Laufzeitverlängerung von mehr als 12 Monaten sind beide Parteien berechtigt, eine angemessene Anpassung der Vergütungssätze entsprechend dem Verbraucherpreisindex zu verlangen. + +§ 3 Termine und Vertragsstrafe bei Verzug + +(1) Die produktive Bereitstellung (Go-Live) der Gesamtlösung erfolgt spätestens am 15.11.2026. + +(2) Gerät der Auftragnehmer mit dem verbindlichen Fertigstellungstermin schuldhaft in Verzug, verwirkt er für jede vollendete Kalenderwoche des Verzugs eine Vertragsstrafe in Höhe von 0,3 % der Netto-Auftragssumme, maximal jedoch 3,0 % der Gesamtauftragssumme. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 2 von 4 +=== [SEITE 3] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Gegenentwurf v2.0 (Stand: 18.04.2026) · Seite 3 von 4 + +§ 4 Gewährleistung und Haftung + +(1) Die Verjährungsfrist für Mängelansprüche beträgt 12 Monate ab schriftlicher Endabnahme durch den Auftraggeber. + +(2) Die Haftung der Parteien für leichte Fahrlässigkeit ist bei Verletzung wesentlicher Vertragspflichten (Kardinalpflichten) auf den vertragstypischen, vorhersehbaren Schaden, höchstens jedoch auf 250.000,00 € je Schadensfall begrenzt. + +§ 5 Geheimhaltung, KI-Nutzung und Datenschutz + +(1) Beide Vertragsparteien verpflichten sich, alle im Rahmen der Zusammenarbeit erlangten vertraulichen Informationen für einen Zeitraum von 3 Jahren nach Vertragsende vertraulich zu behandeln. + +(2) Die Verarbeitung personenbezogener Daten erfolgt ausschließlich auf Servern innerhalb der Europäischen Union gemäß dem beigefügten Auftragsverarbeitungsvertrag (Anlage 2). + +(3) NEU: Dem Auftragnehmer ist es gestattet, anonymisierte Projektdaten zur Weiterentwicklung und zum Training eigener KI-gestützter Kalkulationsmodelle zu verwenden. + +§ 6 Kündigung und Schlussbestimmungen + +(1) Dieser Vertrag kann von beiden Parteien mit einer Frist von 6 Monaten zum Jahresende ordentlich gekündigt werden. + +(2) Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Ausschließlicher Gerichtsstand für alle Streitigkeiten aus diesem Vertrag ist München. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 3 von 4`, + }, + en: { + v1: `=== [SEITE 1] === +[[HF]] Master Software & Engineering Agreement · Draft v1.0 (March 12, 2026) · Page 1 of 3 + +MASTER SOFTWARE & ENGINEERING SERVICES AGREEMENT + +Between Apex Construction Partners Ltd., 114 Fleet Street, London (hereinafter referred to as "Client") and Alpha Systems Engineering Inc., 35 Market Street, San Francisco (hereinafter referred to as "Contractor"). + +1. Scope of Services + +(1) The Contractor undertakes to deliver, configure, and document the turnkey cloud cost-estimation platform in accordance with the Statement of Work dated March 12, 2026 (Exhibit A). + +1.1 Core Platform Modules and Deliverables + +• Module A: Quantity takeoff and structured BOQ import (GAEB DA XML 3.3) +• Module B: Baseline cost calculation (Urkalkulation), unit cost breakdown, and overhead markup (Zuschlag) +• Module C: Standard reporting and PDF bid comparison export + +(2) Subsequent change requests by the Client must be submitted in writing and shall be billed separately at the agreed daily rates if they exceed the baseline effort by more than 5.0%. + +(3) The Contractor shall perform all services exclusively using its own permanent, full-time engineering staff. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 1 of 3 +=== [SEITE 2] === +[[HF]] Master Software & Engineering Agreement · Draft v1.0 (March 12, 2026) · Page 2 of 3 + +2. Fees, Baseline Cost Calculation and Payment Terms + +(1) For the services specified in Section 1, the parties agree on a fixed lump-sum fee of EUR 145,000.00 net plus applicable statutory value-added tax. + +2.1 Cost Calculation Basis and Overhead Markups + +(2) Prior to commencement, the Contractor shall deposit a sealed baseline cost calculation (Urkalkulation) disclosing all direct unit costs as well as the overhead markup (Zuschlag) of 12.0% and profit margin of 6.0%. + +(3) Milestone invoices are due and payable without deduction within 14 calendar days of receipt. A 2.0% early-payment discount applies if paid within 7 calendar days. + +3. Delivery Schedule and Liquidated Damages + +(1) Production deployment (Go-Live) of the complete platform shall take place no later than September 30, 2026. + +(2) If the Contractor misses the binding completion date, liquidated damages of 0.5% of the net contract price shall apply per completed calendar week of delay, capped at 5.0% of the total contract value. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 2 of 3 +=== [SEITE 3] === +[[HF]] Master Software & Engineering Agreement · Draft v1.0 (March 12, 2026) · Page 3 of 3 + +4. Warranty and Limitation of Liability + +(1) The warranty period for defects is 24 months from written final acceptance by the Client. + +(2) Liability for slight negligence in the event of a breach of material contractual obligations is limited to foreseeable, typical damages up to a maximum of EUR 500,000.00 per incident. + +(3) Liability for lost profits or indirect consequential damages in cases of slight negligence is excluded. + +5. Confidentiality and Data Protection + +(1) Both parties undertake to keep all confidential information and trade secrets obtained during the collaboration strictly confidential without time limitation. + +(2) Personal data shall be processed exclusively on servers located within the European Union in accordance with the Data Processing Addendum (Exhibit B). + +6. Termination and Governing Law + +(1) Either party may terminate this Agreement for convenience with 3 months' prior written notice to the end of a calendar quarter. + +(2) This Agreement is governed by the laws of the Federal Republic of Germany. Exclusive venue for all disputes arising out of this Agreement is Berlin. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 3 of 3`, + v2: `=== [SEITE 1] === +[[HF]] Master Software & Engineering Agreement · Counter-Proposal v2.0 (April 18, 2026) · Page 1 of 4 + +MASTER SOFTWARE & ENGINEERING SERVICES AGREEMENT + +Between Apex Construction Partners Ltd., 114 Fleet Street, London (hereinafter referred to as "Client") and Alpha Systems Engineering Inc., 35 Market Street, San Francisco (hereinafter referred to as "Contractor"). + +1. Scope of Services + +(1) The Contractor undertakes to deliver, configure, train users on, and document the turnkey cloud cost-estimation platform in accordance with the updated Statement of Work dated April 18, 2026 (Exhibit A). + +1.1 Core Platform Modules and Deliverables + +• Module A: Quantity takeoff and structured BOQ import (GAEB DA XML 3.3 & BIM-IFC) +• Module B: Baseline cost calculation (Urkalkulation), unit cost breakdown, and overhead markup (Zuschlag) +• Module C: Real-time change order management, bid comparison, and automated target/actual variance analysis + +(2) Subsequent change requests by the Client may be submitted in text form and shall be billed separately at the agreed daily rates if they exceed the baseline effort by more than 10.0%. + +(3) The Contractor is entitled to engage qualified subcontractors within the European Union subject to the Client's prior written consent. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 1 of 4 +=== [SEITE 2] === +[[HF]] Master Software & Engineering Agreement · Counter-Proposal v2.0 (April 18, 2026) · Page 2 of 4 + +2. Fees, Baseline Cost Calculation and Payment Terms + +(1) For the services specified in Section 1, the parties agree on a fixed lump-sum fee of EUR 168,500.00 net plus applicable statutory value-added tax. + +2.1 Cost Calculation Basis and Overhead Markups + +(2) Prior to commencement, the Contractor shall deposit a sealed baseline cost calculation (Urkalkulation) disclosing all direct unit costs as well as the overhead markup (Zuschlag) of 14.5% and profit margin of 7.5%. + +(3) Milestone invoices are due and payable without deduction within 30 calendar days of receipt. Early-payment discounts are not granted. + +(4) NEW: Price Adjustment Clause — In the event of a term extension exceeding 12 months, either party may request an adjustment of daily rates aligned with the consumer price index. + +3. Delivery Schedule and Liquidated Damages + +(1) Production deployment (Go-Live) of the complete platform shall take place no later than November 15, 2026. + +(2) If the Contractor culpably misses the binding completion date, liquidated damages of 0.3% of the net contract price shall apply per completed calendar week of delay, capped at 3.0% of the total contract value. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 2 of 4 +=== [SEITE 3] === +[[HF]] Master Software & Engineering Agreement · Counter-Proposal v2.0 (April 18, 2026) · Page 3 of 4 + +4. Warranty and Limitation of Liability + +(1) The warranty period for defects is 12 months from written final acceptance by the Client. + +(2) Liability for slight negligence in the event of a breach of material contractual obligations is limited to foreseeable, typical damages up to a maximum of EUR 250,000.00 per incident. + +5. Confidentiality, AI Training and Data Protection + +(1) Both parties undertake to keep all confidential information obtained during the collaboration strictly confidential for a period of 3 years following termination. + +(2) Personal data shall be processed exclusively on servers located within the European Union in accordance with the Data Processing Addendum (Exhibit B). + +(3) NEW: The Contractor is permitted to use anonymized project telemetry and metadata to train and improve its proprietary AI estimation models. + +6. Termination and Governing Law + +(1) Either party may terminate this Agreement for convenience with 6 months' prior written notice to the end of a calendar year. + +(2) This Agreement is governed by the laws of the Federal Republic of Germany. Exclusive venue for all disputes arising out of this Agreement is Munich. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 3 of 4`, + }, +}; + +const initialLang = + typeof navigator !== 'undefined' && + navigator.language && + navigator.language.toLowerCase().startsWith('de') + ? 'de' + : 'en'; diff --git a/src/js/03-state.js b/src/js/03-state.js new file mode 100644 index 0000000..55df58b --- /dev/null +++ b/src/js/03-state.js @@ -0,0 +1,173 @@ +const isMobileViewport = typeof window !== 'undefined' && window.innerWidth <= 720; +const isTabletOrSmaller = typeof window !== 'undefined' && window.innerWidth <= 960; + +const initialDark = + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches; +if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', initialDark); +} + +const state = { + lang: initialLang, + dark: initialDark, + themeOverridden: false, + viewMode: isMobileViewport ? 'inline' : 'pages', + leftText: '', + rightText: '', + leftName: '', + rightName: '', + leftFormat: '', + rightFormat: '', + alignedRows: [], + changeRows: [], + hoveredRowId: null, + activeRowId: null, + syncScroll: true, + onlyChanges: false, + ignoreHeadersFooters: true, + filteredHfCount: 0, + rightAllBlocks: [], + sidebarFilter: 'all', + searchQuery: '', + isProgrammaticScroll: false, + undoStack: [], + redoStack: [], +}; + +function t(key) { + return I18N[state.lang][key] || I18N.de[key] || key; +} + +const paneLeft = document.getElementById('pane-left'); +const paneRight = document.getElementById('pane-right'); +const paneUnified = document.getElementById('pane-unified'); +const sheetLeft = document.getElementById('sheet-left'); +const sheetRight = document.getElementById('sheet-right'); +const sheetUnified = document.getElementById('sheet-unified'); +const sidebarList = document.getElementById('sidebar-list'); +const connectorSvg = document.getElementById('connector-svg'); +const minimapTrack = document.getElementById('minimap-track'); + +function setTooltip(el, text) { + if (!el) return; + el.setAttribute('data-tooltip', text); + el.removeAttribute('title'); +} + +function applyTranslationsToStaticDom() { + document.documentElement.lang = state.lang; + document.getElementById('btn-lang').textContent = state.lang.toUpperCase(); + setTooltip(document.getElementById('btn-lang'), t('langTitle')); + document.getElementById('i18n-changes-label').textContent = t('changesLabel'); + setTooltip(document.getElementById('stat-summary'), t('statTooltip')); + + const modePagesEl = document.getElementById('i18n-mode-pages'); + if (modePagesEl) modePagesEl.textContent = t('modePages'); + setTooltip(document.getElementById('btn-mode-pages'), t('modePagesTitle')); + document.getElementById('i18n-mode-split').textContent = t('modeSplit'); + setTooltip(document.getElementById('btn-mode-split'), t('modeSplitTitle')); + document.getElementById('i18n-mode-inline').textContent = t('modeInline'); + setTooltip(document.getElementById('btn-mode-inline'), t('modeInlineTitle')); + + document.getElementById('search-input').placeholder = t('searchPlaceholder'); + setTooltip(document.getElementById('search-box-wrap'), t('searchTitle')); + setTooltip(document.getElementById('btn-prev'), t('prevTitle')); + setTooltip(document.getElementById('btn-next'), t('nextTitle')); + + document.getElementById('i18n-btn-only-changes').textContent = t('onlyChanges'); + setTooltip(document.getElementById('btn-only-changes'), t('onlyChangesTitle')); + + const hfBtnLabel = document.getElementById('i18n-btn-filter-hf'); + if (hfBtnLabel) hfBtnLabel.textContent = t('hfFilterBtn'); + setTooltip(document.getElementById('btn-filter-hf'), t('hfFilterTitle')); + + document.getElementById('i18n-btn-text').textContent = t('textBtn'); + setTooltip(document.getElementById('btn-paste-modal'), t('textBtnTitle')); + + document.getElementById('i18n-btn-export').textContent = t('exportBtn'); + setTooltip(document.getElementById('btn-export-menu'), t('exportTitle')); + document.getElementById('i18n-exp-copy').textContent = t('expCopy'); + document.getElementById('i18n-exp-txt').textContent = t('expTxt'); + document.getElementById('i18n-exp-pdf').textContent = t('expPdf'); + document.getElementById('i18n-exp-word').textContent = t('expWord'); + document.getElementById('i18n-exp-html').textContent = t('expHtml'); + document.getElementById('i18n-exp-md').textContent = t('expMd'); + + document.getElementById('i18n-btn-clear').textContent = t('clearBtn'); + setTooltip(document.getElementById('btn-clear'), t('clearBtnTitle')); + + document.getElementById('i18n-btn-help').textContent = t('helpBtn'); + setTooltip(document.getElementById('btn-help'), t('helpBtnTitle')); + + setTooltip(document.getElementById('btn-theme'), t('themeTitle')); + setTooltip(document.getElementById('btn-toggle-sidebar'), t('sidebarToggleTitle')); + + document.getElementById('i18n-mob-filter').textContent = t('mobFilter'); + document.getElementById('i18n-mob-list').textContent = t('mobList'); + document.getElementById('i18n-mob-export').textContent = t('mobExport'); + document.getElementById('i18n-mob-sheet-title').textContent = t('sidebarTitle'); + document.getElementById('btn-close-sidebar-sheet').textContent = t('mobDone'); + + document.getElementById('i18n-col-left').textContent = t('colLeft'); + setTooltip(document.getElementById('i18n-col-left'), t('colLeftTitle')); + document.getElementById('i18n-col-right').textContent = t('colRight'); + setTooltip(document.getElementById('i18n-col-right'), t('colRightTitle')); + + document.getElementById('i18n-badge-editable').textContent = t('badgeEditable'); + setTooltip(document.getElementById('badge-editable'), t('badgeEditableTitle')); + + document.getElementById('i18n-upload-left').textContent = t('fileBtn'); + setTooltip(document.getElementById('btn-upload-left'), t('fileLeftTitle')); + document.getElementById('i18n-upload-right').textContent = t('fileBtn'); + setTooltip(document.getElementById('btn-upload-right'), t('fileRightTitle')); + + setTooltip(document.getElementById('btn-clear-left'), t('clearSideTitle')); + setTooltip(document.getElementById('btn-clear-right'), t('clearSideTitle')); + setTooltip(document.getElementById('btn-swap-docs'), t('swapTitle')); + setTooltip(document.getElementById('btn-undo'), t('undoTitle')); + setTooltip(document.getElementById('btn-redo'), t('redoTitle')); + setTooltip(document.getElementById('dock-step-count'), t('stepCountTitle')); + setTooltip(document.getElementById('sidebar-kbd-hint'), t('sidebarKbdTitle')); + + document.getElementById('i18n-sidebar-title').textContent = t('sidebarTitle'); + document.getElementById('i18n-filter-all').textContent = t('filterAll'); + document.getElementById('i18n-filter-mod').textContent = t('filterMod'); + document.getElementById('i18n-filter-ins').textContent = t('filterIns'); + document.getElementById('i18n-filter-del').textContent = t('filterDel'); + document.getElementById('i18n-drop-left').textContent = t('dropLeft'); + document.getElementById('i18n-drop-right').textContent = t('dropRight'); + + document.getElementById('i18n-modal-paste-title').textContent = t('modalPasteTitle'); + document.getElementById('i18n-modal-left-label').textContent = t('modalLeftLabel'); + document.getElementById('i18n-modal-right-label').textContent = t('modalRightLabel'); + document.getElementById('textarea-left').placeholder = t('modalLeftPh'); + document.getElementById('textarea-right').placeholder = t('modalRightPh'); + document.getElementById('btn-cancel-modal').textContent = t('cancel'); + document.getElementById('btn-apply-modal').textContent = t('compare'); + + document.getElementById('i18n-help-title').textContent = t('helpTitle'); + document.getElementById('i18n-help-body').innerHTML = t('helpBodyHtml'); + document.getElementById('btn-help-sample').textContent = t('helpSampleBtn'); + document.getElementById('btn-ok-help').textContent = t('helpOkBtn'); + + document.getElementById('i18n-privacy-title').textContent = t('privacyTitle'); + document.getElementById('i18n-privacy-body').innerHTML = t('privacyBodyHtml'); + document.getElementById('i18n-footer-privacy').textContent = t('footerPrivacy'); +} + +function setViewMode(mode) { + state.viewMode = mode; + document.body.classList.toggle('view-inline', mode === 'inline'); + document.body.classList.toggle('view-stream', mode === 'split'); + document.body.classList.toggle('view-pages', mode === 'pages'); + const btnPages = document.getElementById('btn-mode-pages'); + if (btnPages) btnPages.setAttribute('aria-pressed', String(mode === 'pages')); + document.getElementById('btn-mode-split').setAttribute('aria-pressed', String(mode === 'split')); + document.getElementById('btn-mode-inline').setAttribute('aria-pressed', String(mode === 'inline')); + requestAnimationFrame(() => { + if (mode !== 'inline') equalizeRowHeights(); + scheduleGutterDraw(); + }); +} diff --git a/src/js/04-core.js b/src/js/04-core.js new file mode 100644 index 0000000..b772390 --- /dev/null +++ b/src/js/04-core.js @@ -0,0 +1,490 @@ +function isLikelyHeaderOrFooterLine(line) { + if (!line || line.length > 130) return false; + if (/^(?:Seite|Page|Blatt|S\.|p\.)\s*\d+(?:\s*(?:von|of|\/)\s*\d+)?$/i.test(line)) return true; + if (/^-\s*\d+\s*-$/.test(line)) return true; + if (/^.*(?:\bSeite|\bPage)\s+\d+\s*(?:von|of|\/)\s*\d+\s*$/i.test(line)) return true; + if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\b.*(?:\bSeite|\bPage)\s+\d+/i.test(line)) { + return true; + } + return false; +} + +function classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) { + if (isHeaderFooter) return 'hf'; + if (/^#\s+/.test(line)) return 'h1'; + if (/^##\s+/.test(line)) return 'h2'; + if (/^###\s+/.test(line)) return 'h3'; + + if ( + isFirstContentBlock && + line.length < 105 && + !/^(§|\(\d+\)|\d+\.|[•‣▪◦–*-]\s)/.test(line) && + (/^[A-ZÄÖÜ0-9\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line)) + ) { + return 'h1'; + } + + if ( + /^(§\s*\d+[a-z]?|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+|\d+\.\s+[A-ZÄÖÜ])/i.test( + line + ) && + line.length < 115 + ) { + return 'h2'; + } + + if ( + /^(\d+\.\d+(?:\.\d+)*)\s+[A-ZÄÖÜa-zäöü]/.test(line) && + line.length < 105 && + !/[.;]$/.test(line) + ) { + return 'h3'; + } + + if (/^[A-ZÄÖÜ\s&-]{10,}$/.test(line) && line.length < 105) { + return isFirstContentBlock ? 'h1' : 'h2'; + } + + if (/^[•‣▪◦–*-]\s+\S/.test(line)) { + return 'bullet'; + } + + if (/^(\(\d+\)|\([a-z]\)|\([ivx]+\)|\d+\)|[a-z]\))\s+\S/i.test(line)) { + return 'clause'; + } + + return 'p'; +} + +function extractListMarker(text, kind) { + if (!text) return null; + if (kind === 'bullet') { + const m = text.match(/^([•‣▪◦–*-])\s+(.*)$/s); + if (m) return { marker: '•', body: m[2] }; + } + if (kind === 'clause') { + const m = text.match(/^(\(\d+\)|\([a-z]\)|\([ivx]+\)|\d+\)|[a-z]\))\s+(.*)$/is); + if (m) return { marker: m[1], body: m[2] }; + } + return null; +} + +function extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') { + if (kind === 'hf') return t('typeHf'); + const secMatch = line.match(/^(§\s*\d+[a-z]?|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+)/i); + if (secMatch) { + return secMatch[1].replace(/\s+/g, ' ').trim(); + } + const numHeadingMatch = line.match(/^(\d+\.)\s+[A-ZÄÖÜ]/); + if (numHeadingMatch && line.length < 115) { + return numHeadingMatch[1]; + } + const subHeadingMatch = line.match(/^(\d+\.\d+(?:\.\d+)*)\s+/); + if (subHeadingMatch && line.length < 115) { + return subHeadingMatch[1]; + } + const subMatch = line.match(/^(\(\d+\)|\([a-z]\)|\d+(?:\.\d+)+|\d+\)|[a-z]\))/i); + if (subMatch) { + const sub = subMatch[1]; + return currentSectionRef ? `${currentSectionRef} ${sub}` : sub; + } + if (kind === 'bullet' && currentSectionRef) { + return `${currentSectionRef} •`; + } + return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`; +} + +function tokenizeWords(str) { + const words = str.toLowerCase().split(/\s+/).filter(Boolean); + return { words, set: new Set(words) }; +} + +function parseIntoBlocks(rawText) { + const normalized = rawText.replace(/\r\n/g, '\n').replace(/\u00A0/g, ' ').trim(); + if (!normalized) return []; + + const rawLines = normalized.split(/\n+/); + const blocks = []; + let currentSectionRef = ''; + let currentPage = 1; + let hasExplicitPageMarkers = false; + let seenContentBlock = false; + + for (let i = 0; i < rawLines.length; i++) { + let line = rawLines[i].trim(); + if (!line) continue; + + const pageBreakMatch = line.match(/^===\s*\[(?:SEITE|PAGE)\s*(\d+)\]\s*===$/i); + if (pageBreakMatch) { + currentPage = Math.max(1, parseInt(pageBreakMatch[1], 10) || currentPage + 1); + hasExplicitPageMarkers = true; + continue; + } + if (line === '\f') { + currentPage++; + hasExplicitPageMarkers = true; + continue; + } + + let isHeaderFooter = false; + if (line.startsWith('[[HF]]')) { + isHeaderFooter = true; + line = line.replace(/^\[\[HF\]\]\s*/, '').trim(); + if (!line) continue; + } else if (isLikelyHeaderOrFooterLine(line)) { + isHeaderFooter = true; + } + + const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter); + if (!isHeaderFooter) { + seenContentBlock = true; + line = line.replace(/^#{1,3}\s+/, '').trim(); + } + + const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3'; + + if (kind === 'h2') { + const secMatch = line.match(/^(§\s*\d+[a-z]?|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+)/i); + const numMatch = line.match(/^(\d+\.)\s+[A-ZÄÖÜ]/); + if (secMatch) { + currentSectionRef = secMatch[1].replace(/\s+/g, ' ').trim(); + } else if (numMatch) { + currentSectionRef = numMatch[1]; + } + } + + const idx = blocks.length + 1; + const { words, set } = tokenizeWords(line); + blocks.push({ + index: idx, + pageNum: currentPage, + hasExplicitPage: hasExplicitPageMarkers, + refCode: extractClauseRef(line, idx, currentSectionRef, kind), + sectionRef: currentSectionRef, + text: line, + kind, + isHeading, + isHeaderFooter, + words, + wordSet: set, + }); + } + + // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length + if (!hasExplicitPageMarkers && blocks.length > 0) { + let autoPage = 1; + let blocksOnCurrentPage = 0; + let charsOnCurrentPage = 0; + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + const startsNewMajorSection = + b.kind === 'h2' && blocksOnCurrentPage >= 5 && charsOnCurrentPage >= 650; + const exceedsPageCapacity = blocksOnCurrentPage >= 10 || charsOnCurrentPage >= 1850; + if (i > 0 && (startsNewMajorSection || exceedsPageCapacity)) { + autoPage++; + blocksOnCurrentPage = 0; + charsOnCurrentPage = 0; + } + b.pageNum = autoPage; + blocksOnCurrentPage++; + charsOnCurrentPage += b.text.length; + } + } + + return blocks; +} + +function computeBlockSimilarity(blockA, blockB) { + if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0; + if (blockA.text === blockB.text) return 1.0; + if (blockA.isHeading !== blockB.isHeading) return 0; + const lenA = blockA.words.length; + const lenB = blockB.words.length; + if (!lenA || !lenB) return 0; + + const sameSpecificRef = + blockA.refCode && + blockA.refCode === blockB.refCode && + !blockA.refCode.startsWith('#') && + !blockA.refCode.includes('#'); + + const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB); + if (maxPossible < 0.28 && !sameSpecificRef) return 0; + + let intersection = 0; + const smallerWords = lenA <= lenB ? blockA.words : blockB.words; + const largerSet = lenA <= lenB ? blockB.wordSet : blockA.wordSet; + + for (let i = 0; i < smallerWords.length; i++) { + if (largerSet.has(smallerWords[i])) intersection++; + } + const wordSim = (2.0 * intersection) / (lenA + lenB); + return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim; +} + +function alignDocumentBlocks(leftBlocks, rightBlocks) { + const totalN = leftBlocks.length; + const totalM = rightBlocks.length; + if (totalN === 0 && totalM === 0) return []; + + let prefixLen = 0; + while ( + prefixLen < totalN && + prefixLen < totalM && + leftBlocks[prefixLen].text === rightBlocks[prefixLen].text + ) { + prefixLen++; + } + + let suffixLen = 0; + while ( + suffixLen < totalN - prefixLen && + suffixLen < totalM - prefixLen && + leftBlocks[totalN - 1 - suffixLen].text === rightBlocks[totalM - 1 - suffixLen].text + ) { + suffixLen++; + } + + const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen); + const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen); + const n = midLeft.length; + const m = midRight.length; + + const rawPairs = []; + for (let p = 0; p < prefixLen; p++) { + rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] }); + } + + if (n > 0 || m > 0) { + const dp = Array.from({ length: n + 1 }, () => new Float32Array(m + 1)); + const ptr = Array.from({ length: n + 1 }, () => new Uint8Array(m + 1)); + const GAP_PENALTY = -0.22; + + for (let i = 1; i <= n; i++) { + dp[i][0] = i * GAP_PENALTY; + ptr[i][0] = 2; + } + for (let j = 1; j <= m; j++) { + dp[0][j] = j * GAP_PENALTY; + ptr[0][j] = 3; + } + + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + const sim = computeBlockSimilarity(midLeft[i - 1], midRight[j - 1]); + const matchScore = sim >= 0.28 ? sim : -0.5; + const scoreDiag = dp[i - 1][j - 1] + matchScore; + const scoreUp = dp[i - 1][j] + GAP_PENALTY; + const scoreLeft = dp[i][j - 1] + GAP_PENALTY; + + if (scoreDiag >= scoreUp && scoreDiag >= scoreLeft) { + dp[i][j] = scoreDiag; + ptr[i][j] = 1; + } else if (scoreUp >= scoreLeft) { + dp[i][j] = scoreUp; + ptr[i][j] = 2; + } else { + dp[i][j] = scoreLeft; + ptr[i][j] = 3; + } + } + } + + const midPairs = []; + let i = n; + let j = m; + while (i > 0 || j > 0) { + const move = ptr[i][j]; + if (i > 0 && j > 0 && move === 1) { + midPairs.push({ left: midLeft[i - 1], right: midRight[j - 1] }); + i--; + j--; + } else if (i > 0 && (j === 0 || move === 2)) { + midPairs.push({ left: midLeft[i - 1], right: null }); + i--; + } else { + midPairs.push({ left: null, right: midRight[j - 1] }); + j--; + } + } + midPairs.reverse(); + for (const pair of midPairs) rawPairs.push(pair); + } + + for (let s = 0; s < suffixLen; s++) { + rawPairs.push({ + left: leftBlocks[totalN - suffixLen + s], + right: rightBlocks[totalM - suffixLen + s], + }); + } + + let monotonicPage = 1; + return rawPairs.map((pair, idx) => { + const rowId = `row-${idx + 1}`; + const rawPage = + (pair.right && pair.right.pageNum) || + (pair.left && pair.left.pageNum) || + monotonicPage; + monotonicPage = Math.max(monotonicPage, rawPage); + + if (pair.left && pair.right) { + if (pair.left.text === pair.right.text) { + return { + id: rowId, + rowNumber: idx + 1, + pageNum: monotonicPage, + type: 'equal', + leftBlock: pair.left, + rightBlock: pair.right, + wordDiffs: [{ value: pair.left.text }], + }; + } else { + const wordDiffs = computeCleanWordDiff(pair.left.text, pair.right.text); + + return { + id: rowId, + rowNumber: idx + 1, + pageNum: monotonicPage, + type: 'modified', + leftBlock: pair.left, + rightBlock: pair.right, + wordDiffs, + }; + } + } else if (pair.left && !pair.right) { + return { + id: rowId, + rowNumber: idx + 1, + pageNum: monotonicPage, + type: 'deleted', + leftBlock: pair.left, + rightBlock: null, + wordDiffs: [{ value: pair.left.text, removed: true }], + }; + } else { + return { + id: rowId, + rowNumber: idx + 1, + pageNum: monotonicPage, + type: 'inserted', + leftBlock: null, + rightBlock: pair.right, + wordDiffs: [{ value: pair.right.text, added: true }], + }; + } + }); +} + +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function computeCleanWordDiff(oldText, newText) { + if (!window.Diff) { + return [ + { value: oldText, removed: true }, + { value: newText, added: true }, + ]; + } + + const raw = window.Diff.diffWordsWithSpace(oldText, newText); + + const mergedNums = []; + for (let i = 0; i < raw.length; i++) { + const cur = raw[i]; + if ( + !cur.added && + !cur.removed && + /^[.,\d]+$/.test(cur.value) && + mergedNums.length > 0 && + i + 1 < raw.length + ) { + const prev = mergedNums[mergedNums.length - 1]; + const next = raw[i + 1]; + if ((prev.removed || prev.added) && (next.removed || next.added)) { + mergedNums.push({ value: cur.value, removed: true }); + mergedNums.push({ value: cur.value, added: true }); + continue; + } + } + mergedNums.push({ ...cur }); + } + + // Expand partial numeric/date diffs (e.g. "0,[5]" -> "[0,5]", "[145.000],00" -> "[145.000,00]", "[12.03].2026" -> "[12.03.2026]") + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + let idx = 0; + while (idx < mergedNums.length) { + if (!isChange(mergedNums[idx])) { + idx++; + continue; + } + const start = idx; + while (idx < mergedNums.length && isChange(mergedNums[idx])) { + idx++; + } + const end = idx - 1; + const groupHasDigit = mergedNums.slice(start, idx).some(t => /\d/.test(t.value)); + if (!groupHasDigit) continue; + + if (start > 0 && !isChange(mergedNums[start - 1])) { + const prevTok = mergedNums[start - 1]; + const prefixMatch = prevTok.value.match(/(\d+[.,]+\d*|\d*[.,]+\d+)$/); + if (prefixMatch) { + const numPrefix = prefixMatch[1]; + prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length); + const firstRem = mergedNums.slice(start, idx).find(t => t.removed); + const firstAdd = mergedNums.slice(start, idx).find(t => t.added); + if (firstRem) firstRem.value = numPrefix + firstRem.value; + if (firstAdd) firstAdd.value = numPrefix + firstAdd.value; + } + } + + if (idx < mergedNums.length && !isChange(mergedNums[idx])) { + const nextTok = mergedNums[idx]; + const suffixMatch = nextTok.value.match(/^([.,]\d+(?:[.,]\d+)*|\d+(?:[.,]\d+)*)/); + if (suffixMatch) { + const numSuffix = suffixMatch[1]; + nextTok.value = nextTok.value.slice(numSuffix.length); + let lastRem = null; + let lastAdd = null; + for (let k = start; k <= end; k++) { + if (mergedNums[k].removed) lastRem = mergedNums[k]; + if (mergedNums[k].added) lastAdd = mergedNums[k]; + } + if (lastRem) lastRem.value = lastRem.value + numSuffix; + if (lastAdd) lastAdd.value = lastAdd.value + numSuffix; + } + } + } + + return mergedNums.filter(t => t.value.length > 0); +} + +function coalesceSideTokens(sideParts, prop) { + const out = []; + for (let i = 0; i < sideParts.length; i++) { + const cur = sideParts[i]; + if ( + !cur[prop] && + /^\s+$/.test(cur.value) && + out.length > 0 && + out[out.length - 1][prop] && + i + 1 < sideParts.length && + sideParts[i + 1][prop] + ) { + out[out.length - 1].value += cur.value; + continue; + } + if (cur[prop] && out.length > 0 && out[out.length - 1][prop]) { + out[out.length - 1].value += cur.value; + } else { + out.push({ value: cur.value, [prop]: Boolean(cur[prop]) }); + } + } + return out; +} diff --git a/src/js/05-render.js b/src/js/05-render.js new file mode 100644 index 0000000..e18fcd6 --- /dev/null +++ b/src/js/05-render.js @@ -0,0 +1,423 @@ +// Entity-safe search highlighting: splits raw text BEFORE escaping so HTML entities are never corrupted +function escapeAndHighlightText(rawText, query) { + if (!rawText) return ''; + if (!query || query.trim().length < 2) return escapeHtml(rawText); + const q = query.trim(); + const safeQ = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${safeQ})`, 'gi'); + const parts = rawText.split(regex); + if (parts.length === 1) return escapeHtml(rawText); + + const qLower = q.toLowerCase(); + return parts + .map(part => + part.toLowerCase() === qLower + ? `${escapeHtml(part)}` + : escapeHtml(part) + ) + .join(''); +} + +function setSafeContainerHtml(container, trustedHtmlString) { + container.innerHTML = trustedHtmlString; +} + +function wrapMarkerAndBody(markerHtml, bodyHtml) { + return `${markerHtml}
${bodyHtml}
`; +} + +function renderLeftBlockHtml(row, query) { + const b = row.leftBlock; + if (!b) return ''; + const split = extractListMarker(b.text, b.kind); + + if (row.type === 'equal') { + if (split) { + return wrapMarkerAndBody( + escapeAndHighlightText(split.marker, query), + escapeAndHighlightText(split.body, query) + ); + } + return escapeAndHighlightText(b.text, query); + } + if (row.type === 'deleted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(b.text, query)}`; + } + if (row.type === 'modified') { + const rSplit = row.rightBlock ? extractListMarker(row.rightBlock.text, row.rightBlock.kind) : null; + if (split && rSplit) { + const bodyDiffs = computeCleanWordDiff(split.body, rSplit.body); + const sideParts = coalesceSideTokens( + bodyDiffs.filter(part => !part.added), + 'removed' + ); + const bodyHtml = sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.removed ? `${content}` : content; + }) + .join(''); + const markerHtml = + split.marker === rSplit.marker + ? escapeAndHighlightText(split.marker, query) + : `${escapeAndHighlightText(split.marker, query)}`; + return wrapMarkerAndBody(markerHtml, bodyHtml); + } + + const sideParts = coalesceSideTokens( + row.wordDiffs.filter(part => !part.added), + 'removed' + ); + return sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.removed ? `${content}` : content; + }) + .join(''); + } + return ''; +} + +function renderRightBlockHtml(row, query) { + const b = row.rightBlock; + if (!b) return ''; + const split = extractListMarker(b.text, b.kind); + + if (row.type === 'equal') { + if (split) { + return wrapMarkerAndBody( + escapeAndHighlightText(split.marker, query), + escapeAndHighlightText(split.body, query) + ); + } + return escapeAndHighlightText(b.text, query); + } + if (row.type === 'inserted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(b.text, query)}`; + } + if (row.type === 'modified') { + const lSplit = row.leftBlock ? extractListMarker(row.leftBlock.text, row.leftBlock.kind) : null; + if (split && lSplit) { + const bodyDiffs = computeCleanWordDiff(lSplit.body, split.body); + const sideParts = coalesceSideTokens( + bodyDiffs.filter(part => !part.removed), + 'added' + ); + const bodyHtml = sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.added ? `${content}` : content; + }) + .join(''); + const markerHtml = + split.marker === lSplit.marker + ? escapeAndHighlightText(split.marker, query) + : `${escapeAndHighlightText(split.marker, query)}`; + return wrapMarkerAndBody(markerHtml, bodyHtml); + } + + const sideParts = coalesceSideTokens( + row.wordDiffs.filter(part => !part.removed), + 'added' + ); + return sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.added ? `${content}` : content; + }) + .join(''); + } + return ''; +} + +function coalesceUnifiedTokens(wordDiffs) { + const out = []; + let i = 0; + const isChange = t => Boolean(t && (t.removed || t.added)); + const isBridgeSpace = idx => + idx > 0 && + idx + 1 < wordDiffs.length && + !isChange(wordDiffs[idx]) && + /^\s+$/.test(wordDiffs[idx].value) && + isChange(wordDiffs[idx - 1]) && + isChange(wordDiffs[idx + 1]); + + while (i < wordDiffs.length) { + if (!isChange(wordDiffs[i])) { + out.push({ value: wordDiffs[i].value }); + i++; + continue; + } + const run = []; + while (i < wordDiffs.length && (isChange(wordDiffs[i]) || isBridgeSpace(i))) { + run.push(wordDiffs[i]); + i++; + } + const hasRem = run.some(t => t.removed); + const hasAdd = run.some(t => t.added); + if (hasRem && hasAdd) { + const delStr = run + .filter(t => !t.added) + .map(t => t.value) + .join(''); + const insStr = run + .filter(t => !t.removed) + .map(t => t.value) + .join(''); + out.push({ value: delStr, removed: true }); + out.push({ value: ' ' }); + out.push({ value: insStr, added: true }); + } else if (hasRem) { + out.push({ value: run.map(t => t.value).join(''), removed: true }); + } else if (hasAdd) { + out.push({ value: run.map(t => t.value).join(''), added: true }); + } + } + return out; +} + +function renderUnifiedBlockHtml(row, query, allowMarkerGrid = false) { + const primary = row.rightBlock || row.leftBlock; + const split = allowMarkerGrid && primary ? extractListMarker(primary.text, primary.kind) : null; + + if (row.type === 'equal') { + if (split) { + return wrapMarkerAndBody( + escapeAndHighlightText(split.marker, query), + escapeAndHighlightText(split.body, query) + ); + } + return escapeAndHighlightText(row.rightBlock.text, query); + } + if (row.type === 'deleted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(row.leftBlock.text, query)}`; + } + if (row.type === 'inserted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(row.rightBlock.text, query)}`; + } + if (row.type === 'modified') { + const lSplit = row.leftBlock ? extractListMarker(row.leftBlock.text, row.leftBlock.kind) : null; + const rSplit = row.rightBlock ? extractListMarker(row.rightBlock.text, row.rightBlock.kind) : null; + if (allowMarkerGrid && lSplit && rSplit) { + const bodyDiffs = computeCleanWordDiff(lSplit.body, rSplit.body); + const bodyHtml = coalesceUnifiedTokens(bodyDiffs) + .map(part => { + const content = escapeAndHighlightText(part.value, query); + if (part.removed) return `${content}`; + if (part.added) return `${content}`; + return content; + }) + .join(''); + const markerHtml = + lSplit.marker === rSplit.marker + ? escapeAndHighlightText(rSplit.marker, query) + : `${escapeAndHighlightText(lSplit.marker, query)} ${escapeAndHighlightText(rSplit.marker, query)}`; + return wrapMarkerAndBody(markerHtml, bodyHtml); + } + + return coalesceUnifiedTokens(row.wordDiffs) + .map(part => { + const content = escapeAndHighlightText(part.value, query); + if (part.removed) return `${content}`; + if (part.added) return `${content}`; + return content; + }) + .join(''); + } + return ''; +} + +function renderGhostSheet() { + return ` + + `; +} + +function renderLaunchpadHtml(leftCount, rightCount) { + const leftLoaded = leftCount > 0; + const rightLoaded = rightCount > 0; + const leftSlotClass = leftLoaded ? 'is-loaded' : 'is-next-step'; + const rightSlotClass = rightLoaded ? 'is-loaded' : leftLoaded ? 'is-next-step' : ''; + + const leftTitle = leftLoaded ? state.leftName || 'Vorher' : t('lpLeftTitle'); + const leftHint = leftLoaded ? `${t('lpLoaded')} · ${leftCount} ${t('unitPara')}` : t('lpLeftHint'); + const leftBtnClass = !leftLoaded ? 'btn btn-primary' : 'btn'; + const leftBtnLabel = leftLoaded ? t('lpChangeFile') : t('chooseFileLeft'); + + const rightTitle = rightLoaded ? state.rightName || 'Nachher' : t('lpRightTitle'); + const rightHint = rightLoaded ? `${t('lpLoaded')} · ${rightCount} ${t('unitPara')}` : t('lpRightHint'); + const rightBtnClass = leftLoaded && !rightLoaded ? 'btn btn-primary' : 'btn'; + const rightBtnLabel = rightLoaded ? t('lpChangeFile') : t('chooseFileRight'); + + const uploadSvg = ''; + const sepSvg = ''; + + return ` +
+
+

${escapeHtml(t('lpTitle'))}

+

${escapeHtml(t('lpSub'))}

+
+ +
+
+
+ ${escapeHtml(t('colLeft'))} +
+
${escapeHtml(leftTitle)}
+
${escapeHtml(leftHint)}
+ +
+ +
+
+ ${escapeHtml(t('colRight'))} +
+
${escapeHtml(rightTitle)}
+
${escapeHtml(rightHint)}
+ +
+
+ +
+ + +
+ + +
+ `; +} + +function updateMobileBarState() { + const total = state.changeRows.length; + const curIdx = state.changeRows.findIndex(r => r.id === state.activeRowId); + const countEl = document.getElementById('mob-step-count'); + if (countEl) { + countEl.textContent = total > 0 ? `${curIdx >= 0 ? curIdx + 1 : '–'}/${total}` : '0/0'; + } + const dockCountEl = document.getElementById('dock-step-count'); + if (dockCountEl) { + dockCountEl.textContent = total > 0 ? `${curIdx >= 0 ? curIdx + 1 : '–'} / ${total}` : '0 / 0'; + dockCountEl.classList.toggle('has-active', total > 0 && curIdx >= 0); + } + const prevBtn = document.getElementById('mob-btn-prev'); + const nextBtn = document.getElementById('mob-btn-next'); + const dockPrevBtn = document.getElementById('btn-prev'); + const dockNextBtn = document.getElementById('btn-next'); + if (prevBtn) prevBtn.disabled = total === 0; + if (nextBtn) nextBtn.disabled = total === 0; + if (dockPrevBtn) dockPrevBtn.disabled = total === 0; + if (dockNextBtn) dockNextBtn.disabled = total === 0; + const badgeEl = document.getElementById('mob-changes-badge'); + if (badgeEl) badgeEl.textContent = String(total); + const mobFilterBtn = document.getElementById('mob-btn-only-changes'); + if (mobFilterBtn) mobFilterBtn.setAttribute('aria-pressed', String(state.onlyChanges)); + const hfBtn = document.getElementById('btn-filter-hf'); + if (hfBtn) hfBtn.setAttribute('aria-pressed', String(state.ignoreHeadersFooters)); + const hfBadge = document.getElementById('hf-count-badge'); + if (hfBadge) { + hfBadge.textContent = String(state.filteredHfCount); + hfBadge.style.display = state.filteredHfCount > 0 ? 'inline-flex' : 'none'; + } +} + +function updateColumnMeta(side, blocksCount, pagesCount = 0) { + const metaEl = document.getElementById(side === 'left' ? 'meta-left' : 'meta-right'); + const clearBtn = document.getElementById(side === 'left' ? 'btn-clear-left' : 'btn-clear-right'); + const fmt = side === 'left' ? state.leftFormat : state.rightFormat; + + if (blocksCount > 0) { + const pageStr = pagesCount > 0 ? `${pagesCount} ${t('pageShort')} · ` : ''; + metaEl.textContent = fmt + ? `${fmt} · ${pageStr}${blocksCount} ${t('unitPara')}` + : `${pageStr}${blocksCount} ${t('unitPara')}`; + metaEl.classList.add('is-visible'); + clearBtn.style.display = 'inline-flex'; + } else { + metaEl.textContent = ''; + metaEl.classList.remove('is-visible'); + clearBtn.style.display = 'none'; + } +} + +function getClauseTypeClasses(block) { + if (!block) return ''; + const classes = []; + if (block.isHeading) classes.push('is-heading'); + if (block.kind) classes.push(`kind-${block.kind}`); + if (extractListMarker(block.text, block.kind)) classes.push('has-marker'); + return classes.join(' '); +} diff --git a/src/js/06-compare.js b/src/js/06-compare.js new file mode 100644 index 0000000..df9dcef --- /dev/null +++ b/src/js/06-compare.js @@ -0,0 +1,630 @@ +function runComparison() { + const allLeftBlocks = parseIntoBlocks(state.leftText); + const allRightBlocks = parseIntoBlocks(state.rightText); + state.rightAllBlocks = allRightBlocks; + + const hfLeft = allLeftBlocks.filter(b => b.isHeaderFooter).length; + const hfRight = allRightBlocks.filter(b => b.isHeaderFooter).length; + state.filteredHfCount = Math.max(hfLeft, hfRight); + + const leftBlocks = state.ignoreHeadersFooters + ? allLeftBlocks.filter(b => !b.isHeaderFooter) + : allLeftBlocks; + const rightBlocks = state.ignoreHeadersFooters + ? allRightBlocks.filter(b => !b.isHeaderFooter) + : allRightBlocks; + + const leftPages = leftBlocks.reduce((m, b) => Math.max(m, b.pageNum || 1), 0); + const rightPages = rightBlocks.reduce((m, b) => Math.max(m, b.pageNum || 1), 0); + + document.getElementById('filename-left').textContent = state.leftName || t('noFileLeft'); + document.getElementById('filename-right').textContent = state.rightName || t('noFileRight'); + + updateColumnMeta('left', leftBlocks.length, leftPages); + updateColumnMeta('right', rightBlocks.length, rightPages); + + const bothLoaded = leftBlocks.length > 0 && rightBlocks.length > 0; + document.body.classList.toggle('has-docs', leftBlocks.length > 0 || rightBlocks.length > 0); + document.body.classList.toggle('is-comparing', bothLoaded); + const badgeEditable = document.getElementById('badge-editable'); + if (badgeEditable) { + badgeEditable.classList.toggle('is-visible', bothLoaded); + } + + const emptyLaunchpad = document.getElementById('empty-launchpad'); + + if (!bothLoaded) { + state.alignedRows = []; + state.changeRows = []; + document.getElementById('count-all').textContent = '0'; + document.getElementById('count-ins').textContent = '0'; + document.getElementById('count-del').textContent = '0'; + updateMobileBarState(); + updateUndoRedoButtons(); + + if (leftBlocks.length === 0) { + setSafeContainerHtml(sheetLeft, renderGhostSheet()); + } else { + setSafeContainerHtml( + sheetLeft, + `
${leftBlocks + .map( + b => + `
${escapeAndHighlightText(b.text, state.searchQuery)}
` + ) + .join('')}
` + ); + } + + if (rightBlocks.length === 0) { + setSafeContainerHtml(sheetRight, renderGhostSheet()); + } else { + setSafeContainerHtml( + sheetRight, + `
${rightBlocks + .map( + b => + `
${escapeAndHighlightText(b.text, state.searchQuery)}
` + ) + .join('')}
` + ); + } + + setSafeContainerHtml(sheetUnified, renderGhostSheet()); + if (emptyLaunchpad) { + setSafeContainerHtml(emptyLaunchpad, renderLaunchpadHtml(leftBlocks.length, rightBlocks.length)); + } + + bindEmptyStateButtons(); + renderSidebarList(bothLoaded); + minimapTrack.innerHTML = ''; + connectorSvg.innerHTML = ''; + return; + } + + if (emptyLaunchpad) { + emptyLaunchpad.innerHTML = ''; + } + + state.alignedRows = alignDocumentBlocks(leftBlocks, rightBlocks); + state.changeRows = state.alignedRows.filter(r => r.type !== 'equal'); + + const modCount = state.alignedRows.filter(r => r.type === 'modified').length; + const insCount = state.alignedRows.filter(r => r.type === 'inserted').length; + const delCount = state.alignedRows.filter(r => r.type === 'deleted').length; + + document.getElementById('count-all').textContent = String(modCount + insCount + delCount); + document.getElementById('count-ins').textContent = String(insCount + modCount); + document.getElementById('count-del').textContent = String(delCount + modCount); + + renderDocumentPanes(); + renderSidebarList(true); + renderMinimap(); + updateMobileBarState(); + updateUndoRedoButtons(); + + if (state.activeRowId) { + document.querySelectorAll(`[data-row-id="${state.activeRowId}"]`).forEach(el => { + if ( + el.classList.contains('clause') || + el.classList.contains('nav-item') || + el.classList.contains('minimap-tick') + ) { + el.classList.add('is-active'); + } + }); + } + + requestAnimationFrame(() => { + if (state.viewMode !== 'inline') equalizeRowHeights(); + scheduleGutterDraw(); + }); +} + +function bindEmptyStateButtons() { + document.querySelectorAll('#btn-empty-upload-left').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + document.getElementById('file-input-left').click(); + }); + }); + document.querySelectorAll('#btn-empty-upload-right').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + document.getElementById('file-input-right').click(); + }); + }); + + const slotLeft = document.getElementById('lp-slot-left'); + const slotRight = document.getElementById('lp-slot-right'); + [ + { el: slotLeft, side: 'left', inputId: 'file-input-left' }, + { el: slotRight, side: 'right', inputId: 'file-input-right' }, + ].forEach(({ el, side, inputId }) => { + if (!el) return; + el.addEventListener('click', () => document.getElementById(inputId).click()); + el.addEventListener('dragover', e => { + e.preventDefault(); + e.stopPropagation(); + el.classList.add('drag-over'); + }); + el.addEventListener('dragleave', () => { + el.classList.remove('drag-over'); + }); + el.addEventListener('drop', async e => { + e.preventDefault(); + e.stopPropagation(); + el.classList.remove('drag-over'); + dragCounter = 0; + document.body.classList.remove('is-dragging-file'); + await handleDropFiles(e.dataTransfer.files, side); + }); + }); + + const lpOverlay = document.getElementById('empty-launchpad'); + if (lpOverlay && !lpOverlay.dataset.dropBound) { + lpOverlay.dataset.dropBound = '1'; + lpOverlay.addEventListener('dragover', e => e.preventDefault()); + lpOverlay.addEventListener('drop', async e => { + e.preventDefault(); + dragCounter = 0; + document.body.classList.remove('is-dragging-file'); + const defaultSide = state.leftText && !state.rightText ? 'right' : 'left'; + await handleDropFiles(e.dataTransfer.files, defaultSide); + }); + } + + document.querySelectorAll('.btn-open-paste').forEach(btn => { + btn.addEventListener('click', () => openPasteModal()); + }); + + document.querySelectorAll('.btn-open-privacy').forEach(btn => { + btn.addEventListener('click', () => document.getElementById('privacy-modal').showModal()); + }); + + document.querySelectorAll('.btn-load-sample').forEach(btn => { + btn.addEventListener('click', () => { + const sample = SAMPLES[state.lang] || SAMPLES.de; + state.leftName = t('sampleLeftName'); + state.leftFormat = 'DEMO'; + state.leftText = sample.v1; + state.rightName = t('sampleRightName'); + state.rightFormat = 'DEMO'; + state.rightText = sample.v2; + runComparison(); + }); + }); +} + +function renderRightActionsHtml(row) { + const editLabel = row.rightBlock ? t('editBtn') : t('editInsertBtn'); + const editSvg = row.rightBlock + ? '' + : ''; + const revertSvg = ''; + const editBtn = ``; + const revertBtn = + row.type !== 'equal' + ? `` + : ''; + return `
${editBtn}${revertBtn}
`; +} + +function renderPageCardHeaderHtml(pageNum, totalPages, rowsInPage) { + const sections = []; + for (const r of rowsInPage) { + const b = r.rightBlock || r.leftBlock; + if (b && b.sectionRef && !sections.includes(b.sectionRef)) { + sections.push(b.sectionRef); + } + } + const secRange = + sections.length === 1 + ? sections[0] + : sections.length > 1 + ? `${sections[0]} – ${sections[sections.length - 1]}` + : ''; + const changeCount = rowsInPage.filter(r => r.type !== 'equal').length; + const pillLabel = + changeCount > 0 ? `${changeCount} ${escapeHtml(t('changesLabel'))}` : escapeHtml(t('pageUnchanged')); + + return ` +
+
+ ${escapeHtml(t('pageLabel'))} ${pageNum} ${escapeHtml(t('pageOf'))} ${totalPages} + ${secRange ? `· ${escapeHtml(secRange)}` : ''} +
+ ${pillLabel} +
+ `; +} + +function renderDocumentPanes() { + const q = state.searchQuery; + const pageGroups = new Map(); + + state.alignedRows.forEach((row, idx) => { + const p = row.pageNum || 1; + if (!pageGroups.has(p)) pageGroups.set(p, []); + pageGroups.get(p).push({ row, idx }); + }); + + const totalPages = pageGroups.size || 1; + const leftPageCards = []; + const rightPageCards = []; + const unifiedPageCards = []; + + for (const [pageNum, entries] of pageGroups.entries()) { + const rowsInPage = entries.map(e => e.row); + const pageHeaderHtml = renderPageCardHeaderHtml(pageNum, totalPages, rowsInPage); + const leftRowsHtml = []; + const rightRowsHtml = []; + const unifiedRowsHtml = []; + + for (const { row, idx } of entries) { + const deferClass = idx > 18 ? 'deferred-block' : ''; + const rightActionsHtml = renderRightActionsHtml(row); + + if (row.leftBlock) { + const typeClasses = getClauseTypeClasses(row.leftBlock); + const stateClass = + row.type === 'equal' + ? 'state-equal' + : row.type === 'modified' + ? 'state-modified' + : 'state-deleted'; + + leftRowsHtml.push(` +
+
${renderLeftBlockHtml(row, q)}
+
+ `); + } else { + leftRowsHtml.push(` +
+
+
+ `); + } + + if (row.rightBlock) { + const typeClasses = getClauseTypeClasses(row.rightBlock); + const stateClass = + row.type === 'equal' + ? 'state-equal' + : row.type === 'modified' + ? 'state-modified' + : 'state-inserted'; + + rightRowsHtml.push(` +
+
${renderRightBlockHtml(row, q)}${rightActionsHtml}
+
+ `); + } else { + rightRowsHtml.push(` +
+
${rightActionsHtml}
+
+ `); + } + + const primaryBlock = row.rightBlock || row.leftBlock; + const uTypeClasses = getClauseTypeClasses(primaryBlock); + const uStateClass = + row.type === 'equal' + ? 'state-equal' + : row.type === 'modified' + ? 'state-modified' + : row.type === 'inserted' + ? 'state-inserted' + : 'state-deleted'; + + unifiedRowsHtml.push(` +
+
${renderUnifiedBlockHtml(row, q, true)}${rightActionsHtml}
+
+ `); + } + + leftPageCards.push(` +
+ ${pageHeaderHtml} +
${leftRowsHtml.join('')}
+
+ `); + rightPageCards.push(` +
+ ${pageHeaderHtml} +
${rightRowsHtml.join('')}
+
+ `); + unifiedPageCards.push(` +
+ ${pageHeaderHtml} +
${unifiedRowsHtml.join('')}
+
+ `); + } + + setSafeContainerHtml(sheetLeft, leftPageCards.join('')); + setSafeContainerHtml(sheetRight, rightPageCards.join('')); + setSafeContainerHtml(sheetUnified, unifiedPageCards.join('')); +} + +function equalizeRowHeights() { + if (state.viewMode === 'inline') return; + const leftAnchors = sheetLeft.querySelectorAll('.diff-row-anchor'); + const rightAnchors = sheetRight.querySelectorAll('.diff-row-anchor'); + const len = Math.min(leftAnchors.length, rightAnchors.length); + + for (let i = 0; i < len; i++) { + const lBlock = leftAnchors[i].firstElementChild; + const rBlock = rightAnchors[i].firstElementChild; + if (!lBlock || !rBlock) continue; + lBlock.style.minHeight = ''; + rBlock.style.minHeight = ''; + } + + const heights = new Array(len); + for (let i = 0; i < len; i++) { + const lBlock = leftAnchors[i].firstElementChild; + const rBlock = rightAnchors[i].firstElementChild; + if (!lBlock || !rBlock) { + heights[i] = 0; + continue; + } + heights[i] = Math.max(lBlock.offsetHeight, rBlock.offsetHeight); + } + + for (let i = 0; i < len; i++) { + if (!heights[i]) continue; + leftAnchors[i].firstElementChild.style.minHeight = `${heights[i]}px`; + rightAnchors[i].firstElementChild.style.minHeight = `${heights[i]}px`; + } +} + +function truncateClean(str, maxLen = 40) { + const clean = str.replace(/\s+/g, ' ').trim(); + if (clean.length <= maxLen) return clean; + const cut = clean.slice(0, maxLen); + const lastSpace = cut.lastIndexOf(' '); + return (lastSpace > 16 ? cut.slice(0, lastSpace) : cut).replace(/[,;:.]+$/, '') + '…'; +} + +function extractDeltaPairs(wordDiffs) { + const pairs = []; + let i = 0; + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const isBridgeSpace = idx => + idx > 0 && + idx + 1 < wordDiffs.length && + !isChange(wordDiffs[idx]) && + /^\s+$/.test(wordDiffs[idx].value) && + isChange(wordDiffs[idx - 1]) && + isChange(wordDiffs[idx + 1]); + const isSingleWordBridge = idx => + idx > 0 && + idx + 1 < wordDiffs.length && + !isChange(wordDiffs[idx]) && + /^\s*\S{1,12}\s*$/.test(wordDiffs[idx].value) && + isChange(wordDiffs[idx - 1]) && + isChange(wordDiffs[idx + 1]) && + (wordDiffs[idx - 1].value.trim().includes(' ') || wordDiffs[idx + 1].value.trim().includes(' ')); + + while (i < wordDiffs.length) { + if (!isChange(wordDiffs[i])) { + i++; + continue; + } + const run = []; + while ( + i < wordDiffs.length && + (isChange(wordDiffs[i]) || isBridgeSpace(i) || isSingleWordBridge(i)) + ) { + run.push(wordDiffs[i]); + i++; + } + const rawDel = run + .filter(tok => !tok.added) + .map(tok => tok.value) + .join('') + .replace(/\s+/g, ' ') + .replace(/^[,;\s]+|[,;\s]+$/g, '') + .trim(); + const rawIns = run + .filter(tok => !tok.removed) + .map(tok => tok.value) + .join('') + .replace(/\s+/g, ' ') + .replace(/^[,;\s]+|[,;\s]+$/g, '') + .trim(); + + if (!rawDel && !rawIns) continue; + pairs.push({ del: rawDel, ins: rawIns }); + } + return pairs; +} + +function renderSidebarDeltaHtml(row, query) { + if (row.type === 'deleted') { + return ``; + } + if (row.type === 'inserted') { + return ``; + } + const pairs = extractDeltaPairs(row.wordDiffs || []); + if (pairs.length === 0) { + return ``; + } + const maxPairs = 3; + const visible = pairs.slice(0, maxPairs); + const rowsHtml = visible + .map(pair => { + if (pair.del && pair.ins) { + return ``; + } + if (pair.ins) { + return ``; + } + return ``; + }) + .join(''); + const moreHtml = + pairs.length > maxPairs + ? `` + : ''; + return ``; +} + +function getFilteredChangeRows() { + return state.changeRows.filter(row => { + if (state.sidebarFilter !== 'all' && row.type !== state.sidebarFilter) { + return false; + } + if (state.searchQuery.trim().length >= 2) { + const q = state.searchQuery.toLowerCase(); + const lText = row.leftBlock ? row.leftBlock.text.toLowerCase() : ''; + const rText = row.rightBlock ? row.rightBlock.text.toLowerCase() : ''; + return lText.includes(q) || rText.includes(q); + } + return true; + }); +} + +function updateSidebarHeaderState() { + const total = state.changeRows.length; + const activeIdx = state.changeRows.findIndex(r => r.id === state.activeRowId); + const badge = document.getElementById('sidebar-count-badge'); + if (badge) { + if (total > 0 && activeIdx >= 0) { + badge.textContent = `${activeIdx + 1} / ${total}`; + badge.classList.add('has-active'); + } else { + badge.textContent = String(total); + badge.classList.remove('has-active'); + } + } + + const modCount = state.changeRows.filter(r => r.type === 'modified').length; + const insCount = state.changeRows.filter(r => r.type === 'inserted').length; + const delCount = state.changeRows.filter(r => r.type === 'deleted').length; + + const cAll = document.getElementById('filter-count-all'); + const cMod = document.getElementById('filter-count-mod'); + const cIns = document.getElementById('filter-count-ins'); + const cDel = document.getElementById('filter-count-del'); + if (cAll) cAll.textContent = String(total); + if (cMod) cMod.textContent = String(modCount); + if (cIns) cIns.textContent = String(insCount); + if (cDel) cDel.textContent = String(delCount); + + document.querySelectorAll('[data-sidebar-filter]').forEach(btn => { + const f = btn.getAttribute('data-sidebar-filter'); + const isActive = f === state.sidebarFilter; + btn.classList.toggle('is-active', isActive); + btn.setAttribute('aria-selected', String(isActive)); + const countForTab = + f === 'all' ? total : f === 'modified' ? modCount : f === 'inserted' ? insCount : delCount; + btn.classList.toggle('is-zero', countForTab === 0); + }); +} + +function renderSidebarList(bothLoaded) { + updateSidebarHeaderState(); + if (!bothLoaded) { + setSafeContainerHtml( + sidebarList, + ` +
+ +

${escapeHtml(t('sidebarEmptyTitle'))}

+
${escapeHtml(t('sidebarEmptyDesc'))}
+
+ ` + ); + return; + } + + const filtered = getFilteredChangeRows(); + + if (filtered.length === 0) { + const isSearchOrFilter = state.searchQuery.trim().length >= 2 || state.sidebarFilter !== 'all'; + setSafeContainerHtml( + sidebarList, + ` +
+ +

${escapeHtml(isSearchOrFilter ? t('noMatchesTitle') : t('identicalTitle'))}

+
${escapeHtml(isSearchOrFilter ? t('noMatchesDesc') : t('identicalDesc'))}
+
+ ` + ); + return; + } + + const editSvg = ''; + const revertSvg = ''; + + setSafeContainerHtml( + sidebarList, + filtered + .map(row => { + const globalIdx = state.changeRows.indexOf(row) + 1; + const cls = row.type === 'modified' ? 'mod' : row.type === 'inserted' ? 'ins' : 'del'; + const isActive = row.id === state.activeRowId; + const typeLabel = + row.type === 'modified' ? t('typeMod') : row.type === 'inserted' ? t('typeIns') : t('typeDel'); + const refCode = row.rightBlock ? row.rightBlock.refCode : row.leftBlock.refCode; + const pageNum = row.pageNum || 1; + const deltaHtml = renderSidebarDeltaHtml(row, state.searchQuery); + + return ` + + `; + }) + .join('') + ); +} diff --git a/src/js/07-interactions.js b/src/js/07-interactions.js new file mode 100644 index 0000000..fd2ce82 --- /dev/null +++ b/src/js/07-interactions.js @@ -0,0 +1,482 @@ +function renderMinimap() { + const totalRows = state.alignedRows.length || 1; + setSafeContainerHtml( + minimapTrack, + state.changeRows + .map(row => { + const pct = ((row.rowNumber - 1) / totalRows) * 100; + const cls = row.type === 'modified' ? 'mod' : row.type === 'inserted' ? 'ins' : 'del'; + return `
`; + }) + .join('') + ); +} + +let gutterRafPending = false; +function scheduleGutterDraw() { + if (gutterRafPending) return; + gutterRafPending = true; + requestAnimationFrame(() => { + gutterRafPending = false; + drawGutterConnectors(); + }); +} + +function drawGutterConnectors() { + if (state.viewMode === 'inline') { + connectorSvg.innerHTML = ''; + return; + } + const gutterRect = connectorSvg.getBoundingClientRect(); + if (!gutterRect.width || !gutterRect.height) return; + + const targetIds = new Set([state.hoveredRowId, state.activeRowId].filter(Boolean)); + if (targetIds.size === 0) { + connectorSvg.innerHTML = ''; + return; + } + + const paths = []; + const w = gutterRect.width; + + targetIds.forEach(rowId => { + const leftEl = sheetLeft.querySelector(`.clause[data-row-id="${rowId}"]`); + const rightEl = sheetRight.querySelector(`.clause[data-row-id="${rowId}"]`); + if (!leftEl || !rightEl) return; + + const lRect = leftEl.getBoundingClientRect(); + const rRect = rightEl.getBoundingClientRect(); + + const yLeft = lRect.top + lRect.height / 2 - gutterRect.top; + const yRight = rRect.top + rRect.height / 2 - gutterRect.top; + + const d = `M 0 ${yLeft} C ${w * 0.45} ${yLeft}, ${w * 0.55} ${yRight}, ${w} ${yRight}`; + paths.push(``); + }); + + connectorSvg.innerHTML = paths.join(''); +} + +function setHoveredRow(rowId) { + if (state.hoveredRowId === rowId) return; + state.hoveredRowId = rowId; + + document.querySelectorAll('.clause.is-hovered, .nav-item.is-hovered').forEach(el => { + el.classList.remove('is-hovered'); + }); + + if (rowId) { + document.querySelectorAll(`[data-row-id="${rowId}"]`).forEach(el => { + if (el.classList.contains('clause') || el.classList.contains('nav-item')) { + el.classList.add('is-hovered'); + } + }); + } + scheduleGutterDraw(); +} + +let programmaticScrollTimer = null; + +function selectAndScrollToRow(rowId, smooth = true, scrollPanes = true) { + state.activeRowId = rowId; + document + .querySelectorAll('.clause.is-active, .clause.is-jump-flash, .nav-item.is-active, .minimap-tick.is-active') + .forEach(el => { + el.classList.remove('is-active', 'is-jump-flash'); + }); + + updateMobileBarState(); + updateSidebarHeaderState(); + if (!rowId) return; + + document.querySelectorAll(`[data-row-id="${rowId}"]`).forEach(el => { + if ( + el.classList.contains('clause') || + el.classList.contains('nav-item') || + el.classList.contains('minimap-tick') + ) { + el.classList.add('is-active'); + if (scrollPanes && el.classList.contains('clause')) { + el.classList.add('is-jump-flash'); + } + } + }); + + const leftTarget = sheetLeft.querySelector(`.clause[data-row-id="${rowId}"]`); + const rightTarget = sheetRight.querySelector(`.clause[data-row-id="${rowId}"]`); + const unifiedTarget = sheetUnified.querySelector(`.clause[data-row-id="${rowId}"]`); + const navTarget = sidebarList.querySelector(`.nav-item[data-row-id="${rowId}"]`); + + if (scrollPanes) { + // Materialize deferred blocks so exact heights are measured before jumping + document.querySelectorAll('.clause.deferred-block').forEach(el => { + el.classList.remove('deferred-block'); + }); + if (state.viewMode !== 'inline') { + equalizeRowHeights(); + } + + state.isProgrammaticScroll = true; + clearTimeout(programmaticScrollTimer); + + const getScrollTargetTop = (pane, targetEl) => { + const paneRect = pane.getBoundingClientRect(); + const targetRect = targetEl.getBoundingClientRect(); + const offsetInContent = pane.scrollTop + (targetRect.top - paneRect.top); + const leadSpace = Math.max(24, Math.min(130, Math.round(pane.clientHeight * 0.22))); + return Math.max(0, Math.round(offsetInContent - leadSpace)); + }; + + if (state.viewMode === 'inline' && unifiedTarget) { + const topOffset = getScrollTargetTop(paneUnified, unifiedTarget); + paneUnified.scrollTo({ top: topOffset, behavior: smooth ? 'smooth' : 'auto' }); + } else { + if (leftTarget) { + const topOffset = getScrollTargetTop(paneLeft, leftTarget); + paneLeft.scrollTo({ top: topOffset, behavior: smooth ? 'smooth' : 'auto' }); + } + if (rightTarget) { + const topOffset = getScrollTargetTop(paneRight, rightTarget); + paneRight.scrollTo({ top: topOffset, behavior: smooth ? 'smooth' : 'auto' }); + } + } + + programmaticScrollTimer = setTimeout( + () => { + state.isProgrammaticScroll = false; + scheduleGutterDraw(); + }, + smooth ? 480 : 50 + ); + } else { + scheduleGutterDraw(); + } + + if (navTarget) { + navTarget.scrollIntoView({ block: 'nearest', behavior: smooth ? 'smooth' : 'auto' }); + } +} + +function updateUndoRedoButtons() { + const btnUndo = document.getElementById('btn-undo'); + const btnRedo = document.getElementById('btn-redo'); + if (btnUndo) btnUndo.disabled = state.undoStack.length === 0; + if (btnRedo) btnRedo.disabled = state.redoStack.length === 0; +} + +function pushUndoSnapshot() { + state.undoStack.push(state.rightText); + if (state.undoStack.length > 50) state.undoStack.shift(); + state.redoStack = []; + updateUndoRedoButtons(); +} + +function performUndo() { + if (!state.undoStack.length) return; + state.redoStack.push(state.rightText); + state.rightText = state.undoStack.pop(); + runComparison(); +} + +function performRedo() { + if (!state.redoStack.length) return; + state.undoStack.push(state.rightText); + state.rightText = state.redoStack.pop(); + runComparison(); +} + +function rebuildRightTextFromRows(targetRowId, replacementText) { + const hasExplicit = state.rightAllBlocks && state.rightAllBlocks.some(b => b.hasExplicitPage); + const hfByPage = new Map(); + if (state.ignoreHeadersFooters && state.rightAllBlocks) { + for (const b of state.rightAllBlocks) { + if (b.isHeaderFooter) { + const p = b.pageNum || 1; + if (!hfByPage.has(p)) hfByPage.set(p, []); + hfByPage.get(p).push(`[[HF]] ${b.text}`); + } + } + } + + const pagesMap = new Map(); + for (const r of state.alignedRows) { + const p = r.pageNum || 1; + if (!pagesMap.has(p)) pagesMap.set(p, []); + if (r.id === targetRowId) { + if (replacementText) pagesMap.get(p).push(replacementText); + } else if (r.rightBlock) { + const prefix = r.rightBlock.isHeaderFooter ? '[[HF]] ' : ''; + pagesMap.get(p).push(`${prefix}${r.rightBlock.text}`); + } + } + + const outLines = []; + for (const [pageNum, contentLines] of pagesMap.entries()) { + if (hasExplicit) { + outLines.push(`=== [SEITE ${pageNum}] ===`); + } + const hfs = hfByPage.get(pageNum) || []; + if (hfs.length > 0) outLines.push(hfs[0]); + for (const line of contentLines) outLines.push(line); + if (hfs.length > 1) { + for (let k = 1; k < hfs.length; k++) outLines.push(hfs[k]); + } + } + return outLines.join('\n\n'); +} + +function revertRowToOriginal(targetRowId) { + if (!state.alignedRows.length) return; + const targetRow = state.alignedRows.find(r => r.id === targetRowId); + if (!targetRow) return; + pushUndoSnapshot(); + const revertedText = targetRow.leftBlock ? targetRow.leftBlock.text : ''; + state.rightText = rebuildRightTextFromRows(targetRowId, revertedText); + runComparison(); +} + +function startInlineClauseEdit(clauseEl, rowId) { + if (!clauseEl || clauseEl.classList.contains('is-editing')) return; + const row = state.alignedRows.find(r => r.id === rowId); + if (!row) return; + + hideTooltip(); + + const initialText = row.rightBlock + ? row.rightBlock.text + : row.leftBlock + ? row.leftBlock.text + : ''; + const originalHtml = clauseEl.innerHTML; + + clauseEl.classList.add('is-editing'); + const editor = document.createElement('textarea'); + editor.className = 'clause-inline-editor'; + editor.value = initialText; + + const helperBar = document.createElement('div'); + helperBar.className = 'clause-editor-bar'; + helperBar.innerHTML = ` + ${escapeHtml(t('editorHintText'))} +
+ + +
+ `; + + clauseEl.innerHTML = ''; + clauseEl.appendChild(editor); + clauseEl.appendChild(helperBar); + + const autoSize = () => { + editor.style.height = 'auto'; + editor.style.height = `${Math.max(32, editor.scrollHeight)}px`; + if (state.viewMode !== 'inline') equalizeRowHeights(); + scheduleGutterDraw(); + }; + autoSize(); + editor.addEventListener('input', autoSize); + editor.focus(); + editor.setSelectionRange(editor.value.length, editor.value.length); + + let finished = false; + const commit = () => { + if (finished) return; + finished = true; + const newText = editor.value.replace(/\r\n/g, '\n').trim(); + const oldText = row.rightBlock ? row.rightBlock.text : ''; + if (newText === oldText) { + clauseEl.classList.remove('is-editing'); + clauseEl.innerHTML = originalHtml; + if (state.viewMode !== 'inline') equalizeRowHeights(); + scheduleGutterDraw(); + return; + } + pushUndoSnapshot(); + state.rightText = rebuildRightTextFromRows(rowId, newText); + runComparison(); + }; + + const cancel = () => { + if (finished) return; + finished = true; + clauseEl.classList.remove('is-editing'); + clauseEl.innerHTML = originalHtml; + if (state.viewMode !== 'inline') equalizeRowHeights(); + scheduleGutterDraw(); + }; + + helperBar.querySelector('[data-editor-cancel]').addEventListener('mousedown', ev => { + ev.preventDefault(); + ev.stopPropagation(); + cancel(); + }); + helperBar.querySelector('[data-editor-save]').addEventListener('mousedown', ev => { + ev.preventDefault(); + ev.stopPropagation(); + commit(); + }); + + editor.addEventListener('keydown', ev => { + if (ev.key === 'Escape') { + ev.preventDefault(); + ev.stopPropagation(); + cancel(); + } else if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey || !ev.shiftKey)) { + ev.preventDefault(); + ev.stopPropagation(); + commit(); + } + }); + editor.addEventListener('blur', () => { + commit(); + }); +} + +function closeMobileSheets() { + document.body.classList.remove('mobile-sheet-open'); + exportMenu.classList.remove('is-open'); + btnExportMenu.setAttribute('aria-expanded', 'false'); + const mobExpBtn = document.getElementById('mob-btn-export'); + if (mobExpBtn) mobExpBtn.setAttribute('aria-expanded', 'false'); + if (window.innerWidth <= 960) { + document.body.classList.add('sidebar-collapsed'); + document.getElementById('btn-toggle-sidebar').setAttribute('aria-pressed', 'false'); + const mobChangesBtn = document.getElementById('mob-btn-changes'); + if (mobChangesBtn) mobChangesBtn.setAttribute('aria-pressed', 'false'); + } +} + +[sheetLeft, sheetRight, sheetUnified, sidebarList].forEach(container => { + container.addEventListener('mouseover', e => { + const target = e.target.closest('[data-row-id]'); + if (target) setHoveredRow(target.getAttribute('data-row-id')); + }); + container.addEventListener('mouseleave', () => setHoveredRow(null)); + container.addEventListener('click', e => { + const editBtn = e.target.closest('[data-edit-row]'); + if (editBtn) { + e.stopPropagation(); + const rowId = editBtn.getAttribute('data-edit-row'); + if (container === sidebarList) { + selectAndScrollToRow(rowId, false, true); + if (window.innerWidth <= 960) closeMobileSheets(); + } + const activeSheet = state.viewMode === 'inline' ? sheetUnified : sheetRight; + const hostClause = + editBtn.closest('.clause[data-row-id]') || + activeSheet.querySelector(`.clause[data-row-id="${rowId}"]`); + if (hostClause) startInlineClauseEdit(hostClause, rowId); + return; + } + const revertBtn = e.target.closest('[data-revert-row]'); + if (revertBtn) { + e.stopPropagation(); + hideTooltip(); + revertRowToOriginal(revertBtn.getAttribute('data-revert-row')); + return; + } + if (e.target.closest('.clause-inline-editor') || e.target.closest('.clause-editor-bar')) return; + const target = e.target.closest('[data-row-id]'); + if (target) { + const fromSidebar = container === sidebarList; + selectAndScrollToRow(target.getAttribute('data-row-id'), true, fromSidebar); + if (window.innerWidth <= 960 && fromSidebar) { + closeMobileSheets(); + } + } + }); +}); + +sidebarList.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { + const navItem = e.target.closest('.nav-item[data-row-id]'); + if (navItem && !e.target.closest('button')) { + e.preventDefault(); + selectAndScrollToRow(navItem.getAttribute('data-row-id'), true, true); + if (window.innerWidth <= 960) closeMobileSheets(); + } + } +}); + +const sidebarFilterBar = document.getElementById('sidebar-filter-bar'); +if (sidebarFilterBar) { + sidebarFilterBar.addEventListener('click', e => { + const btn = e.target.closest('[data-sidebar-filter]'); + if (!btn) return; + const nextFilter = btn.getAttribute('data-sidebar-filter'); + state.sidebarFilter = + state.sidebarFilter === nextFilter && nextFilter !== 'all' ? 'all' : nextFilter; + renderSidebarList(state.alignedRows.length > 0); + }); +} + +document.getElementById('btn-undo').addEventListener('click', () => performUndo()); +document.getElementById('btn-redo').addEventListener('click', () => performRedo()); + +// Editing in the Right ("Nachher / Neuer Stand") pane and Unified ("Kompakt") pane +[sheetRight, sheetUnified].forEach(editableSheet => { + editableSheet.addEventListener('dblclick', e => { + if ( + e.target.closest('[data-revert-row]') || + e.target.closest('[data-edit-row]') || + e.target.closest('.clause-inline-editor') || + e.target.closest('.clause-editor-bar') + ) { + return; + } + const clauseEl = e.target.closest('.clause[data-row-id]'); + if (clauseEl) { + startInlineClauseEdit(clauseEl, clauseEl.getAttribute('data-row-id')); + } + }); +}); + +minimapTrack.addEventListener('click', e => { + const tick = e.target.closest('[data-row-id]'); + if (tick) selectAndScrollToRow(tick.getAttribute('data-row-id'), true); +}); + +function handlePaneScroll(sourcePane, targetPane) { + if (Math.abs(targetPane.scrollTop - sourcePane.scrollTop) > 0.5) { + targetPane.scrollTop = sourcePane.scrollTop; + } + scheduleGutterDraw(); +} + +paneLeft.addEventListener('scroll', () => handlePaneScroll(paneLeft, paneRight), { passive: true }); +paneRight.addEventListener('scroll', () => handlePaneScroll(paneRight, paneLeft), { passive: true }); +document.getElementById('center-gutter').addEventListener( + 'wheel', + e => { + if (state.viewMode === 'inline') return; + paneRight.scrollTop += e.deltaY; + paneLeft.scrollTop = paneRight.scrollTop; + scheduleGutterDraw(); + }, + { passive: true } +); + +function attachScrollEnd(scroller, callback) { + if ('onscrollend' in window) { + scroller.addEventListener('scrollend', callback, { passive: true }); + } else { + let timer = null; + scroller.addEventListener('scroll', () => { + clearTimeout(timer); + timer = setTimeout(callback, 100); + }, { passive: true }); + } +} + +attachScrollEnd(paneLeft, () => scheduleGutterDraw()); +attachScrollEnd(paneRight, () => scheduleGutterDraw()); + +let resizeTimer = null; +window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + equalizeRowHeights(); + scheduleGutterDraw(); + }, 60); +}); diff --git a/src/js/08-export.js b/src/js/08-export.js new file mode 100644 index 0000000..48a8ad1 --- /dev/null +++ b/src/js/08-export.js @@ -0,0 +1,181 @@ +/* ── Client-Side Multi-Format Export Engine (PDF, Word .doc, HTML, Markdown) */ +function downloadBlob(content, mimeType, filename) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function buildExportHtmlDocument(forWord = false) { + const rows = state.onlyChanges ? state.changeRows : state.alignedRows; + const title = `Doc Diff — ${state.leftName || 'Original'} vs. ${state.rightName || 'Revised'}`; + const bodyRows = rows + .map(row => { + const primaryBlock = row.rightBlock || row.leftBlock; + const isHeading = primaryBlock && primaryBlock.isHeading; + const tag = isHeading ? 'h3' : 'p'; + const borderColor = + row.type === 'modified' + ? '#0058b8' + : row.type === 'inserted' + ? '#2a9d4e' + : row.type === 'deleted' + ? '#b30027' + : 'transparent'; + const bgColor = + row.type === 'inserted' + ? '#f2fbf5' + : row.type === 'deleted' + ? '#fff5f7' + : 'transparent'; + return `<${tag} style="margin:0 0 8px 0;padding:4px 10px;border-left:3px solid ${borderColor};background:${bgColor};font-size:${ + isHeading ? '13px' : '11.5px' + };line-height:1.5;">${renderUnifiedBlockHtml(row, '')}`; + }) + .join('\n'); + + return ` + + + +${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+
${escapeHtml(t('colLeft'))}: ${escapeHtml(state.leftName || '-')} · ${escapeHtml(t('colRight'))}: ${escapeHtml(state.rightName || '-')} · ${state.changeRows.length} ${escapeHtml(t('changesLabel'))}
+
+
+${bodyRows} +
+ +`; +} + +function buildExportMarkdown() { + const rows = state.onlyChanges ? state.changeRows : state.alignedRows; + const lines = [ + `# Doc Diff: ${state.leftName || 'Original'} vs. ${state.rightName || 'Revised'}`, + '', + `- **${t('colLeft')}:** ${state.leftName || '-'}`, + `- **${t('colRight')}:** ${state.rightName || '-'}`, + `- **${t('sidebarTitle')}:** ${state.changeRows.length}`, + '', + '---', + '', + ]; + + for (const row of rows) { + const primaryBlock = row.rightBlock || row.leftBlock; + const prefix = primaryBlock && primaryBlock.isHeading ? '### ' : ''; + if (row.type === 'equal') { + lines.push(`${prefix}${row.rightBlock.text}`); + } else if (row.type === 'deleted') { + lines.push(`${prefix}~~${row.leftBlock.text}~~`); + } else if (row.type === 'inserted') { + lines.push(`${prefix}**${row.rightBlock.text}**`); + } else if (row.type === 'modified') { + const mdDiff = coalesceUnifiedTokens(row.wordDiffs) + .map(p => (p.removed ? `~~${p.value}~~` : p.added ? `**${p.value}**` : p.value)) + .join(''); + lines.push(`${prefix}${mdDiff}`); + } + lines.push(''); + } + return lines.join('\n'); +} + +let copyLabelTimer = null; +function copyFinalTextToClipboard() { + const finalText = state.alignedRows + .filter(r => r.rightBlock && !r.rightBlock.isHeaderFooter) + .map(r => r.rightBlock.text) + .join('\n\n'); + + const flashCopied = () => { + const labelEl = document.getElementById('i18n-btn-export'); + const mobLabelEl = document.getElementById('i18n-mob-export'); + if (labelEl) labelEl.textContent = t('copiedLabel'); + if (mobLabelEl) mobLabelEl.textContent = t('copiedLabel'); + clearTimeout(copyLabelTimer); + copyLabelTimer = setTimeout(() => { + if (labelEl) labelEl.textContent = t('exportBtn'); + if (mobLabelEl) mobLabelEl.textContent = t('mobExport'); + }, 1800); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(finalText).then(flashCopied).catch(() => { + const ta = document.createElement('textarea'); + ta.value = finalText; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + flashCopied(); + }); + } else { + const ta = document.createElement('textarea'); + ta.value = finalText; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + flashCopied(); + } +} + +function handleExportAction(type) { + if (type === 'print') { + window.print(); + return; + } + if (!state.alignedRows.length) { + const sample = SAMPLES[state.lang] || SAMPLES.de; + state.leftName = t('sampleLeftName'); + state.leftFormat = 'DEMO'; + state.leftText = sample.v1; + state.rightName = t('sampleRightName'); + state.rightFormat = 'DEMO'; + state.rightText = sample.v2; + runComparison(); + } + if (type === 'copy') { + copyFinalTextToClipboard(); + return; + } + const baseName = `DocDiff_${(state.rightName || 'Comparison').replace(/\.[^.]+$/, '')}`; + if (type === 'txt') { + const finalText = state.alignedRows + .filter(r => r.rightBlock && !r.rightBlock.isHeaderFooter) + .map(r => r.rightBlock.text) + .join('\n\n'); + downloadBlob(finalText, 'text/plain;charset=utf-8', `${baseName}_Neu.txt`); + } else if (type === 'word') { + const html = buildExportHtmlDocument(true); + downloadBlob('\ufeff' + html, 'application/msword;charset=utf-8', `${baseName}_Redline.doc`); + } else if (type === 'html') { + const html = buildExportHtmlDocument(false); + downloadBlob(html, 'text/html;charset=utf-8', `${baseName}_Report.html`); + } else if (type === 'md') { + const md = buildExportMarkdown(); + downloadBlob(md, 'text/markdown;charset=utf-8', `${baseName}_Report.md`); + } +} diff --git a/src/js/09-import.js b/src/js/09-import.js new file mode 100644 index 0000000..60fff26 --- /dev/null +++ b/src/js/09-import.js @@ -0,0 +1,256 @@ +async function extractPdfText(arrayBuffer) { + const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise; + const pageSections = []; + + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { + const page = await pdf.getPage(pageNum); + const viewport = page.getViewport({ scale: 1.0 }); + const pageHeight = viewport.height || 842; + const textContent = await page.getTextContent(); + + let lastY = null; + let currentLine = ''; + let currentLineY = 0; + const rawLines = []; + + for (const item of textContent.items) { + const str = item.str; + if (!str) continue; + const y = item.transform ? item.transform[5] : 0; + + if (lastY !== null && Math.abs(y - lastY) > 5.5) { + if (currentLine.trim()) { + rawLines.push({ text: currentLine.trim(), y: currentLineY }); + } + if (Math.abs(y - lastY) > 15) { + rawLines.push({ text: '', y }); + } + currentLine = str; + currentLineY = y; + } else { + if (!currentLine) currentLineY = y; + currentLine += (currentLine && !currentLine.endsWith(' ') && !str.startsWith(' ') ? ' ' : '') + str; + } + lastY = y; + } + if (currentLine.trim()) { + rawLines.push({ text: currentLine.trim(), y: currentLineY }); + } + + const paragraphs = [`=== [SEITE ${pageNum}] ===`]; + let buffer = ''; + + for (let i = 0; i < rawLines.length; i++) { + const entry = rawLines[i]; + const line = entry.text; + if (!line) { + if (buffer) { + paragraphs.push(buffer); + buffer = ''; + } + continue; + } + + const inTopOrBottomMargin = + pdf.numPages > 1 && + (entry.y > pageHeight * 0.91 || entry.y < pageHeight * 0.085) && + line.length < 120; + + if (inTopOrBottomMargin || isLikelyHeaderOrFooterLine(line)) { + if (buffer) { + paragraphs.push(buffer); + buffer = ''; + } + paragraphs.push(`[[HF]] ${line}`); + continue; + } + + const isNewStructuralBlock = + /^(§\s*\d+|\(\d+\)|\d+\.\d+|\d+\.|[•‣▪◦–*-]\s|\([a-z]\)|[a-z]\)|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+)/i.test( + line + ); + if (isNewStructuralBlock && buffer) { + paragraphs.push(buffer); + buffer = line; + } else { + buffer = buffer ? `${buffer} ${line}` : line; + } + } + if (buffer) paragraphs.push(buffer); + pageSections.push(paragraphs.join('\n\n')); + } + return pageSections.join('\n\n'); +} + +async function extractDocxText(arrayBuffer) { + if (window.mammoth && typeof window.mammoth.convertToHtml === 'function') { + const htmlResult = await window.mammoth.convertToHtml({ arrayBuffer }); + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlResult.value || '', 'text/html'); + const out = []; + + const walkNodes = root => { + for (const node of root.children) { + const tag = node.tagName.toLowerCase(); + if (tag === 'h1') { + const txt = (node.textContent || '').trim(); + if (txt) out.push(`# ${txt}`); + } else if (tag === 'h2') { + const txt = (node.textContent || '').trim(); + if (txt) out.push(`## ${txt}`); + } else if (tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') { + const txt = (node.textContent || '').trim(); + if (txt) out.push(`### ${txt}`); + } else if (tag === 'ul') { + node.querySelectorAll(':scope > li').forEach(li => { + const txt = (li.textContent || '').replace(/\s+/g, ' ').trim(); + if (txt) out.push(`• ${txt}`); + }); + } else if (tag === 'ol') { + let idx = 1; + node.querySelectorAll(':scope > li').forEach(li => { + const txt = (li.textContent || '').replace(/\s+/g, ' ').trim(); + if (txt) out.push(`(${idx++}) ${txt}`); + }); + } else if (tag === 'table') { + node.querySelectorAll('tr').forEach(tr => { + const cells = Array.from(tr.querySelectorAll('th, td')) + .map(c => (c.textContent || '').replace(/\s+/g, ' ').trim()) + .filter(Boolean); + if (cells.length) out.push(cells.join(' · ')); + }); + } else if (tag === 'p') { + const txt = (node.textContent || '').replace(/\s+/g, ' ').trim(); + if (txt) out.push(txt); + } else if (node.children && node.children.length > 0) { + walkNodes(node); + } else { + const txt = (node.textContent || '').replace(/\s+/g, ' ').trim(); + if (txt) out.push(txt); + } + } + }; + + walkNodes(doc.body); + if (out.length > 0) return out.join('\n\n'); + } + + const result = await window.mammoth.extractRawText({ arrayBuffer }); + return result.value; +} + +async function handleFileUpload(file, side) { + if (!file) return; + const fnLabel = document.getElementById(side === 'left' ? 'filename-left' : 'filename-right'); + const prevName = fnLabel.textContent; + fnLabel.textContent = `${t('readingFile')} ${file.name}…`; + + try { + const ext = (file.name.split('.').pop() || '').toLowerCase(); + let extractedText = ''; + + if (ext === 'pdf') { + const buffer = await file.arrayBuffer(); + extractedText = await extractPdfText(buffer); + } else if (ext === 'docx') { + const buffer = await file.arrayBuffer(); + extractedText = await extractDocxText(buffer); + } else { + extractedText = await file.text(); + } + + if (side === 'left') { + state.leftName = file.name; + state.leftFormat = ext.toUpperCase() || 'TEXT'; + state.leftText = extractedText; + } else { + state.rightName = file.name; + state.rightFormat = ext.toUpperCase() || 'TEXT'; + state.rightText = extractedText; + } + runComparison(); + } catch (err) { + fnLabel.textContent = prevName; + alert(`${t('readError')} "${file.name}": ${err.message}`); + } +} + +let dragCounter = 0; +window.addEventListener('dragenter', e => { + if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) { + dragCounter++; + document.body.classList.add('is-dragging-file'); + } +}); +window.addEventListener('dragleave', () => { + dragCounter = Math.max(0, dragCounter - 1); + if (dragCounter === 0) document.body.classList.remove('is-dragging-file'); +}); +window.addEventListener('dragover', e => e.preventDefault()); +window.addEventListener('drop', e => { + e.preventDefault(); + dragCounter = 0; + document.body.classList.remove('is-dragging-file'); +}); + +async function handleDropFiles(files, targetSide) { + if (!files || !files.length) return; + if (files.length >= 2) { + await handleFileUpload(files[0], 'left'); + await handleFileUpload(files[1], 'right'); + } else { + await handleFileUpload(files[0], targetSide); + } +} + +[ + { el: paneLeft, side: 'left' }, + { el: paneRight, side: 'right' }, + { el: paneUnified, side: 'right' }, + { el: document.querySelector('.col-bar-left'), side: 'left' }, + { el: document.querySelector('.col-bar-right'), side: 'right' }, +].forEach(({ el, side }) => { + if (!el) return; + el.addEventListener('dragover', e => { + e.preventDefault(); + (side === 'left' ? paneLeft : paneRight).classList.add('drag-over'); + }); + el.addEventListener('dragleave', () => { + (side === 'left' ? paneLeft : paneRight).classList.remove('drag-over'); + }); + el.addEventListener('drop', async e => { + e.preventDefault(); + e.stopPropagation(); + dragCounter = 0; + document.body.classList.remove('is-dragging-file'); + paneLeft.classList.remove('drag-over'); + paneRight.classList.remove('drag-over'); + const actualSide = !state.leftText ? 'left' : side; + await handleDropFiles(e.dataTransfer.files, actualSide); + }); +}); + +const fileInputLeft = document.getElementById('file-input-left'); +const fileInputRight = document.getElementById('file-input-right'); +document.getElementById('btn-upload-left').addEventListener('click', () => fileInputLeft.click()); +document.getElementById('btn-upload-right').addEventListener('click', () => fileInputRight.click()); +fileInputLeft.addEventListener('change', e => handleFileUpload(e.target.files[0], 'left')); +fileInputRight.addEventListener('change', e => handleFileUpload(e.target.files[0], 'right')); + +document.getElementById('btn-clear-left').addEventListener('click', () => { + state.leftText = ''; + state.leftName = ''; + state.leftFormat = ''; + fileInputLeft.value = ''; + runComparison(); +}); + +document.getElementById('btn-clear-right').addEventListener('click', () => { + state.rightText = ''; + state.rightName = ''; + state.rightFormat = ''; + state.undoStack = []; + state.redoStack = []; + fileInputRight.value = ''; + runComparison(); +}); diff --git a/src/js/10-controls.js b/src/js/10-controls.js new file mode 100644 index 0000000..c057c52 --- /dev/null +++ b/src/js/10-controls.js @@ -0,0 +1,391 @@ +function stepChange(direction) { + const pool = getFilteredChangeRows(); + const rows = pool.length > 0 ? pool : state.changeRows; + if (!rows.length) return; + const curIdx = rows.findIndex(r => r.id === state.activeRowId); + let nextIdx = 0; + if (curIdx === -1) { + nextIdx = direction > 0 ? 0 : rows.length - 1; + } else { + nextIdx = (curIdx + direction + rows.length) % rows.length; + } + selectAndScrollToRow(rows[nextIdx].id, true, true); +} + +document.getElementById('btn-prev').addEventListener('click', () => stepChange(-1)); +document.getElementById('btn-next').addEventListener('click', () => stepChange(1)); +document.getElementById('dock-step-count').addEventListener('click', () => { + if (state.activeRowId) { + selectAndScrollToRow(state.activeRowId, true, true); + } else { + stepChange(1); + } +}); +document.getElementById('mob-btn-prev').addEventListener('click', () => stepChange(-1)); +document.getElementById('mob-btn-next').addEventListener('click', () => stepChange(1)); + +const btnModePages = document.getElementById('btn-mode-pages'); +if (btnModePages) btnModePages.addEventListener('click', () => setViewMode('pages')); +document.getElementById('btn-mode-split').addEventListener('click', () => setViewMode('split')); +document.getElementById('btn-mode-inline').addEventListener('click', () => setViewMode('inline')); + +const btnFilterHf = document.getElementById('btn-filter-hf'); +if (btnFilterHf) { + btnFilterHf.addEventListener('click', () => { + state.ignoreHeadersFooters = !state.ignoreHeadersFooters; + btnFilterHf.setAttribute('aria-pressed', String(state.ignoreHeadersFooters)); + runComparison(); + }); +} + +const btnExportMenu = document.getElementById('btn-export-menu'); +const mobBtnExport = document.getElementById('mob-btn-export'); +const exportMenu = document.getElementById('export-menu'); +btnExportMenu.addEventListener('click', e => { + e.stopPropagation(); + const open = exportMenu.classList.toggle('is-open'); + btnExportMenu.setAttribute('aria-expanded', String(open)); +}); +mobBtnExport.addEventListener('click', e => { + e.stopPropagation(); + if (!document.body.classList.contains('sidebar-collapsed')) { + document.body.classList.add('sidebar-collapsed'); + document.getElementById('mob-btn-changes').setAttribute('aria-pressed', 'false'); + } + const open = exportMenu.classList.toggle('is-open'); + mobBtnExport.setAttribute('aria-expanded', String(open)); + document.body.classList.toggle('mobile-sheet-open', open); +}); +exportMenu.addEventListener('click', e => { + const item = e.target.closest('[data-export]'); + if (!item) return; + closeMobileSheets(); + handleExportAction(item.getAttribute('data-export')); +}); +window.addEventListener('click', () => { + exportMenu.classList.remove('is-open'); + btnExportMenu.setAttribute('aria-expanded', 'false'); + mobBtnExport.setAttribute('aria-expanded', 'false'); + if (document.body.classList.contains('sidebar-collapsed')) { + document.body.classList.remove('mobile-sheet-open'); + } +}); + +const searchInput = document.getElementById('search-input'); + +window.addEventListener('keydown', e => { + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) { + if (e.key === 'Escape') document.activeElement.blur(); + return; + } + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && (e.key === 'z' || e.key === 'Z')) { + if (state.undoStack.length > 0) { + e.preventDefault(); + performUndo(); + } + return; + } + if ( + ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'z' || e.key === 'Z')) || + ((e.metaKey || e.ctrlKey) && !e.shiftKey && (e.key === 'y' || e.key === 'Y')) + ) { + if (state.redoStack.length > 0) { + e.preventDefault(); + performRedo(); + } + return; + } + if (e.key === 'j' || e.key === 'J') { + e.preventDefault(); + stepChange(1); + } else if (e.key === 'k' || e.key === 'K') { + e.preventDefault(); + stepChange(-1); + } else if (e.key === '/') { + e.preventDefault(); + searchInput.focus(); + } else if (e.key === 'c' || e.key === 'C') { + e.preventDefault(); + document.getElementById('btn-only-changes').click(); + } else if (e.key === 'h' || e.key === 'H') { + e.preventDefault(); + if (btnFilterHf) btnFilterHf.click(); + } else if (e.key === 'Escape') { + closeMobileSheets(); + } +}); + +const btnOnlyChanges = document.getElementById('btn-only-changes'); +const mobBtnOnlyChanges = document.getElementById('mob-btn-only-changes'); +function toggleOnlyChanges() { + state.onlyChanges = !state.onlyChanges; + document.body.classList.toggle('only-changes', state.onlyChanges); + btnOnlyChanges.setAttribute('aria-pressed', String(state.onlyChanges)); + mobBtnOnlyChanges.setAttribute('aria-pressed', String(state.onlyChanges)); + requestAnimationFrame(() => { + equalizeRowHeights(); + scheduleGutterDraw(); + }); +} +btnOnlyChanges.addEventListener('click', toggleOnlyChanges); +mobBtnOnlyChanges.addEventListener('click', toggleOnlyChanges); + +document.getElementById('btn-clear').addEventListener('click', () => { + state.leftText = ''; + state.rightText = ''; + state.leftName = ''; + state.rightName = ''; + state.leftFormat = ''; + state.rightFormat = ''; + state.activeRowId = null; + state.hoveredRowId = null; + state.sidebarFilter = 'all'; + state.searchQuery = ''; + state.undoStack = []; + state.redoStack = []; + searchInput.value = ''; + fileInputLeft.value = ''; + fileInputRight.value = ''; + runComparison(); +}); + +document.getElementById('btn-lang').addEventListener('click', () => { + state.lang = state.lang === 'de' ? 'en' : 'de'; + if (state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO') { + const sample = SAMPLES[state.lang] || SAMPLES.de; + state.leftName = t('sampleLeftName'); + state.leftText = sample.v1; + state.rightName = t('sampleRightName'); + state.rightText = sample.v2; + } + applyTranslationsToStaticDom(); + runComparison(); +}); + +function applyTheme() { + document.documentElement.classList.toggle('dark', state.dark); + const icon = document.getElementById('theme-icon'); + if (icon) { + if (state.dark) { + icon.innerHTML = + ''; + } else { + icon.innerHTML = ''; + } + } + scheduleGutterDraw(); +} + +document.getElementById('btn-theme').addEventListener('click', () => { + state.themeOverridden = true; + state.dark = !state.dark; + applyTheme(); +}); + +if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { + if (!state.themeOverridden) { + state.dark = e.matches; + applyTheme(); + } + }); +} + +const btnToggleSidebar = document.getElementById('btn-toggle-sidebar'); +const mobBtnChanges = document.getElementById('mob-btn-changes'); +btnToggleSidebar.addEventListener('click', () => { + const collapsed = document.body.classList.toggle('sidebar-collapsed'); + btnToggleSidebar.setAttribute('aria-pressed', String(!collapsed)); + mobBtnChanges.setAttribute('aria-pressed', String(!collapsed)); + requestAnimationFrame(() => { + equalizeRowHeights(); + scheduleGutterDraw(); + }); +}); + +mobBtnChanges.addEventListener('click', e => { + e.stopPropagation(); + exportMenu.classList.remove('is-open'); + mobBtnExport.setAttribute('aria-expanded', 'false'); + const collapsed = document.body.classList.toggle('sidebar-collapsed'); + const isOpen = !collapsed; + btnToggleSidebar.setAttribute('aria-pressed', String(isOpen)); + mobBtnChanges.setAttribute('aria-pressed', String(isOpen)); + document.body.classList.toggle('mobile-sheet-open', isOpen); +}); + +document.getElementById('btn-close-sidebar-sheet').addEventListener('click', closeMobileSheets); +document.getElementById('mobile-sheet-backdrop').addEventListener('click', closeMobileSheets); + +document.getElementById('btn-swap-docs').addEventListener('click', () => { + [state.leftText, state.rightText] = [state.rightText, state.leftText]; + [state.leftName, state.rightName] = [state.rightName, state.leftName]; + [state.leftFormat, state.rightFormat] = [state.rightFormat, state.leftFormat]; + runComparison(); +}); + +searchInput.addEventListener('input', e => { + state.searchQuery = e.target.value; + if (state.alignedRows.length > 0) { + renderDocumentPanes(); + renderSidebarList(true); + requestAnimationFrame(() => { + equalizeRowHeights(); + scheduleGutterDraw(); + }); + } else { + runComparison(); + } +}); + +const pasteModal = document.getElementById('paste-modal'); +const textareaLeft = document.getElementById('textarea-left'); +const textareaRight = document.getElementById('textarea-right'); + +function openPasteModal() { + textareaLeft.value = state.leftText; + textareaRight.value = state.rightText; + pasteModal.showModal(); +} + +document.getElementById('btn-paste-modal').addEventListener('click', openPasteModal); +document.getElementById('btn-close-modal').addEventListener('click', () => pasteModal.close()); +document.getElementById('btn-cancel-modal').addEventListener('click', () => pasteModal.close()); +document.getElementById('btn-apply-modal').addEventListener('click', () => { + state.leftText = textareaLeft.value; + state.rightText = textareaRight.value; + if (state.leftText.trim() && !state.leftName) { + state.leftName = t('manualLeft'); + state.leftFormat = 'TEXT'; + } + if (state.rightText.trim() && !state.rightName) { + state.rightName = t('manualRight'); + state.rightFormat = 'TEXT'; + } + pasteModal.close(); + runComparison(); +}); + +const helpModal = document.getElementById('help-modal'); +document.getElementById('btn-help').addEventListener('click', () => helpModal.showModal()); +document.getElementById('btn-close-help').addEventListener('click', () => helpModal.close()); +document.getElementById('btn-ok-help').addEventListener('click', () => helpModal.close()); +document.getElementById('btn-help-sample').addEventListener('click', () => { + const sample = SAMPLES[state.lang] || SAMPLES.de; + state.leftName = t('sampleLeftName'); + state.leftFormat = 'DEMO'; + state.leftText = sample.v1; + state.rightName = t('sampleRightName'); + state.rightFormat = 'DEMO'; + state.rightText = sample.v2; + helpModal.close(); + runComparison(); +}); + +document.getElementById('badge-editable').addEventListener('click', () => { + if (state.viewMode === 'inline') setViewMode('pages'); + const firstRow = state.changeRows[0] || state.alignedRows[0]; + if (firstRow) { + const clauseEl = sheetRight.querySelector(`.clause[data-row-id="${firstRow.id}"]`); + if (clauseEl) startInlineClauseEdit(clauseEl, firstRow.id); + } +}); + +const privacyModal = document.getElementById('privacy-modal'); +document.getElementById('btn-privacy-info').addEventListener('click', () => privacyModal.showModal()); +document.getElementById('btn-close-privacy').addEventListener('click', () => privacyModal.close()); +document.getElementById('btn-ok-privacy').addEventListener('click', () => privacyModal.close()); + +if (!('closedBy' in HTMLDialogElement.prototype)) { + [pasteModal, helpModal, privacyModal].forEach(dlg => { + dlg.addEventListener('click', event => { + if (event.target !== dlg) return; + const rect = dlg.getBoundingClientRect(); + const isDialogContent = + rect.top <= event.clientY && + event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && + event.clientX <= rect.left + rect.width; + if (isDialogContent) return; + dlg.close(); + }); + }); +} + +/* ── Instant UI Tooltip Controller ────────────────────────────────────── */ +const uiTooltip = document.getElementById('ui-tooltip'); +let tooltipTimer = null; +let activeTooltipTarget = null; + +function showTooltipFor(target) { + const text = target && target.getAttribute('data-tooltip'); + if (!text || !uiTooltip) return; + activeTooltipTarget = target; + uiTooltip.textContent = text; + uiTooltip.setAttribute('aria-hidden', 'false'); + uiTooltip.classList.add('is-visible'); + + const rect = target.getBoundingClientRect(); + const ttRect = uiTooltip.getBoundingClientRect(); + const scopeEl = target.closest('.col-bar, .doc-pane, .sidebar-panel'); + const minX = scopeEl ? Math.max(8, scopeEl.getBoundingClientRect().left + 6) : 8; + const maxX = scopeEl + ? Math.min(window.innerWidth - ttRect.width - 8, scopeEl.getBoundingClientRect().right - ttRect.width - 6) + : window.innerWidth - ttRect.width - 8; + + const floatingBarEl = target.closest('.floating-action-bar'); + let top = floatingBarEl + ? floatingBarEl.getBoundingClientRect().top - ttRect.height - 8 + : rect.bottom + 7; + if (top + ttRect.height > window.innerHeight - 8) { + top = Math.max(8, rect.top - ttRect.height - 7); + } + let left = rect.left + rect.width / 2 - ttRect.width / 2; + if (maxX >= minX) { + left = Math.max(minX, Math.min(maxX, left)); + } else { + left = Math.max(8, Math.min(window.innerWidth - ttRect.width - 8, left)); + } + + uiTooltip.style.top = `${Math.round(top)}px`; + uiTooltip.style.left = `${Math.round(left)}px`; +} + +function hideTooltip() { + clearTimeout(tooltipTimer); + activeTooltipTarget = null; + if (!uiTooltip) return; + uiTooltip.classList.remove('is-visible'); + uiTooltip.setAttribute('aria-hidden', 'true'); +} + +document.addEventListener('mouseover', e => { + const target = e.target.closest('[data-tooltip]'); + if (target === activeTooltipTarget) return; + clearTimeout(tooltipTimer); + if (!target) { + hideTooltip(); + return; + } + tooltipTimer = setTimeout(() => showTooltipFor(target), 220); +}); + +document.addEventListener('focusin', e => { + const target = e.target.closest('[data-tooltip]'); + if (target) showTooltipFor(target); +}); + +document.addEventListener('focusout', () => hideTooltip()); +document.addEventListener('mousedown', () => hideTooltip()); +window.addEventListener('scroll', () => hideTooltip(), { capture: true, passive: true }); + +window.addEventListener('DOMContentLoaded', () => { + if (isTabletOrSmaller) { + document.body.classList.add('sidebar-collapsed'); + btnToggleSidebar.setAttribute('aria-pressed', 'false'); + } + applyTheme(); + setViewMode(state.viewMode); + applyTranslationsToStaticDom(); + runComparison(); +}); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..d1d82f6 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,2967 @@ +@font-face { + font-family: 'Geist'; + src: url('data:font/woff2;base64,__GEIST_B64__') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Geist'; + src: url('data:font/woff2;base64,__GEIST_GREEK_B64__') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: U+0370-03FF; +} +@font-face { + font-family: 'Geist Mono'; + src: url('data:font/woff2;base64,__GEIST_MONO_B64__') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +:root { + color-scheme: light; + --font-sans: 'Geist', system-ui, -apple-system, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + + --text-4xs: 0.6875rem; /* 11px */ + --text-3xs: 0.71875rem; /* 11.5px */ + --text-2xs: 0.78125rem; /* 12.5px */ + --text-xs: 0.84375rem; /* 13.5px */ + --text-chat-label: 0.90625rem;/* 14.5px */ + --text-sm: 0.96875rem; /* 15.5px */ + --text-base: 1.0625rem; /* 17px */ + + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.375rem; /* 6px */ + --radius-lg: 0.5rem; /* 8px */ + --radius-xl: 0.75rem; /* 12px */ + + --background: 0 0% 97.5%; + --foreground: 215 20% 16%; + --card: 0 0% 100%; + + --ton-1: 0 0% 94.9%; + --ton-2: 0 0% 92.2%; + --ton-3: 0 0% 90.2%; + + --muted-foreground: 215 20% 30.2%; + --secondary: 0 0% 78.8%; + --secondary-foreground: 0 0% 9%; + + --border: 0 0% 87.4%; + --input: 0 0% 87.4%; + --ring: 212.8 100% 48.4%; + --trenner: 0 0% 49%; + + --kante-200: rgb(0 0 0 / 0.082); + --kante-300: rgb(0 0 0 / 0.12); + --kante-500: rgb(0 0 0 / 0.212); + + --primary: 212.8 100% 36%; + --primary-foreground: 0 0% 100%; + --primary-ink: 213.5 100% 38%; + --primary-ton-1: 212.8 100.0% 97.0%; + --primary-ton-2: 212.8 100.0% 93.0%; + + --destructive: 347.4 100% 35%; + --destructive-ink: 351 100% 34%; + --destructive-ton-1: 347.4 100.0% 97.0%; + --destructive-ton-2: 347.4 100.0% 93.5%; + + --success: 134.9 61.7% 43.3%; + --success-ink: 138.7 77.3% 22%; + --success-ton-1: 134.9 61.7% 97.0%; + --success-ton-2: 134.9 61.7% 93.5%; + + --warning-ton-2: 41.9 100.0% 92.5%; + + --elev-1: 0px 2px 2px #0000000a; + --elev-2: 0px 1px 2px #00000008, 0px 6px 16px -4px #0000000c; + --elev-3: 0px 1px 1px #00000005, 0px 4px 8px #0000000a, 0px 12px 20px #0000000f; + --elev-4: 0px 1px 1px #00000005, 0px 8px 16px -4px #0000000a, 0px 24px 32px -8px #0000000f; + --overlay: rgb(0 0 0 / 0.8); +} + +.dark { + color-scheme: dark; + --background: 0 0% 2.5%; + --foreground: 0 0% 92.9%; + --card: 0 0% 5.5%; + + --ton-1: 0.0 0.0% 10.2%; + --ton-2: 0.0 0.0% 12.2%; + --ton-3: 0.0 0.0% 16.1%; + + --muted-foreground: 240 6% 74%; + --secondary: 0 0% 27.1%; + --secondary-foreground: 0 0% 92.9%; + + --border: 0 0% 18%; + --input: 0 0% 18%; + --ring: 209.8 100% 65.7%; + --trenner: 0 0% 49%; + + --kante-200: rgb(255 255 255 / 0.09); + --kante-300: rgb(255 255 255 / 0.14); + --kante-500: rgb(255 255 255 / 0.24); + + --primary: 212.4 100% 35.7%; + --primary-foreground: 0 0% 100%; + --primary-ink: 212.4 100% 74.9%; + --primary-ton-1: 218.1 81.3% 12.5%; + --primary-ton-2: 218.1 81.3% 17.5%; + + --destructive: 355 87.2% 37%; + --destructive-ink: 356 100% 74.3%; + --destructive-ton-1: 354.5 64.7% 13.3%; + --destructive-ton-2: 355.0 69.2% 18.0%; + + --success: 132.5 62.6% 42%; + --success-ink: 130.9 62.7% 64.3%; + --success-ton-1: 136.4 55.0% 10.2%; + --success-ton-2: 136.0 58.0% 14.5%; + + --warning-ton-2: 37.0 78.0% 15.0%; + + --elev-1: 0px 2px 2px #00000040; + --elev-2: 0 0 0 1px hsl(0 0% 18%), 0px 6px 16px #00000070; + --elev-3: 0 0 0 1px hsl(0 0% 18%), 0px 8px 20px #00000080; + --elev-4: 0 0 0 1px hsl(0 0% 18%), 0px 16px 32px #00000080; + --overlay: rgb(0 0 0 / 0.85); +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + height: 100dvh; + overflow: hidden; + font-family: var(--font-sans); + font-size: var(--text-xs); + line-height: 1.55; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.font-mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +svg.icon { + width: 15px; + height: 15px; + stroke: currentColor; + stroke-width: 1.85; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + flex-shrink: 0; +} + +/* ── 1. Top Header Bar (44px, PRIME Geist) ────────────────────────────── */ +.header-bar { + height: 44px; + padding: 0 14px; + background: hsl(var(--background)); + border-bottom: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + user-select: none; + position: relative; + z-index: 50; +} + +.header-cluster { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.header-brand { + min-width: 0; + flex-shrink: 0; +} + +.brand-logo { + width: 18px; + height: 18px; + flex-shrink: 0; + display: block; +} + +.app-title { + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: -0.02em; + color: hsl(var(--foreground)); + white-space: nowrap; +} + +.divider-v { + width: 1px; + height: 16px; + background: hsl(var(--border)); + margin: 0 3px; + flex-shrink: 0; +} + +/* Inline Change Stats */ +.stat-summary { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: var(--text-2xs); + color: hsl(var(--muted-foreground)); + white-space: nowrap; +} +.stat-summary strong { + font-weight: 600; + color: hsl(var(--foreground)); +} +.trenner { + color: hsl(var(--trenner)); +} + +/* Buttons (PRIME Geist) */ +.btn { + height: 30px; + padding: 0 10px; + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-size: var(--text-2xs); + font-weight: 500; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + cursor: pointer; + border: 0; + background: hsl(var(--card)); + color: hsl(var(--foreground)); + box-shadow: inset 0 0 0 1px var(--kante-200), var(--elev-1); + transition: background-color 100ms ease-out, box-shadow 100ms ease-out, color 100ms ease-out; + white-space: nowrap; + flex-shrink: 0; +} +.btn:hover { + background: hsl(var(--ton-2)); + box-shadow: inset 0 0 0 1px var(--kante-300), var(--elev-1); +} +.btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px hsl(var(--ring)); +} +.btn[aria-pressed="true"] { + background: hsl(var(--ton-3)); + color: hsl(var(--foreground)); + box-shadow: inset 0 0 0 1px var(--kante-500); +} +.btn:disabled { + opacity: 0.45; + pointer-events: none; +} +.btn-primary { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + box-shadow: none; +} +.btn-primary:hover { + opacity: 0.92; + background: hsl(var(--primary)); + box-shadow: none; +} +.btn-ghost { + background: transparent; + box-shadow: none; + color: hsl(var(--muted-foreground)); +} +.btn-ghost:hover { + background: hsl(var(--ton-2)); + color: hsl(var(--foreground)); + box-shadow: none; +} +.btn-icon { + width: 30px; + padding: 0; +} + +/* Segmented View Mode Pill (Split / Inline) */ +.seg-group { + display: inline-flex; + align-items: center; + height: 30px; + padding: 2px; + border-radius: var(--radius-md); + background: hsl(var(--ton-1)); + box-shadow: inset 0 0 0 1px var(--kante-200); + flex-shrink: 0; +} +.seg-btn { + height: 26px; + padding: 0 9px; + border-radius: var(--radius-sm); + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + font-family: var(--font-sans); + font-size: var(--text-2xs); + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + transition: background-color 100ms ease-out, color 100ms ease-out; +} +.seg-btn:hover { + color: hsl(var(--foreground)); +} +.seg-btn[aria-pressed="true"] { + background: hsl(var(--card)); + color: hsl(var(--foreground)); + box-shadow: 0 0 0 1px var(--kante-200), var(--elev-1); +} + +/* Export Dropdown Menu */ +.dropdown-wrap { + position: relative; + display: inline-flex; +} +.dropdown-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 240px; + padding: 4px; + background: hsl(var(--card)); + border-radius: var(--radius-lg); + box-shadow: 0 0 0 1px hsl(var(--border)), var(--elev-3); + display: none; + flex-direction: column; + gap: 2px; + z-index: 100; +} +.dropdown-menu.is-open { + display: flex; +} +.dropdown-item { + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: hsl(var(--foreground)); + font-family: var(--font-sans); + font-size: var(--text-2xs); + font-weight: 500; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + cursor: pointer; +} +.dropdown-item:hover { + background: hsl(var(--ton-2)); +} +.dropdown-item-left { + display: flex; + align-items: center; + gap: 8px; +} +.dropdown-ext { + font-family: var(--font-mono); + font-size: var(--text-4xs); + color: hsl(var(--muted-foreground)); +} + +/* Search Box */ +.search-box { + position: relative; + display: flex; + align-items: center; + width: 165px; + height: 30px; + background: hsl(var(--card)); + border-radius: var(--radius-md); + box-shadow: inset 0 0 0 1px hsl(var(--input)); + padding: 0 9px; + gap: 6px; + transition: width 120ms ease-out, box-shadow 120ms ease-out; +} +.search-box:focus-within { + width: 210px; + box-shadow: inset 0 0 0 2px hsl(var(--ring)); +} +.search-box input { + border: 0; + outline: 0; + background: transparent; + color: hsl(var(--foreground)); + font-family: var(--font-sans); + font-size: var(--text-2xs); + width: 100%; +} +.search-box input::placeholder { + color: hsl(var(--muted-foreground)); +} +.kbd-hint { + font-family: var(--font-mono); + font-size: var(--text-4xs); + color: hsl(var(--muted-foreground)); + padding: 1px 4px; + border-radius: 2px; + background: hsl(var(--ton-1)); + box-shadow: inset 0 0 0 1px var(--kante-200); + pointer-events: none; +} + +/* ── 2. Sub-Header Document Bars (38px, PRIME Geist) ───────────────────── */ +.col-bars { + height: 38px; + display: grid; + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 260px; + background: hsl(var(--ton-1)); + border-bottom: 1px solid hsl(var(--border)); + user-select: none; +} +body.sidebar-collapsed .col-bars { + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 0px; +} +body.sidebar-collapsed .col-sidebar-head { + display: none !important; +} +body.view-inline .col-bars { + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 260px; +} +body.view-inline.sidebar-collapsed .col-bars { + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 0px; +} + +.col-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + gap: 8px; + min-width: 0; + overflow: visible; +} +.col-bar-left { + padding: 0 8px 0 16px; +} +.col-bar-right { + padding: 0 16px 0 8px; +} + +.col-title-wrap { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 0%; + min-width: 0; + overflow: hidden; +} + +.col-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.col-role-pill { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: var(--text-4xs); + font-weight: 600; + letter-spacing: 0.01em; + flex-shrink: 0; +} +.col-role-old { + background: hsl(var(--ton-2)); + color: hsl(var(--foreground)); + box-shadow: inset 0 0 0 1px var(--kante-300); +} +.col-role-new { + background: hsl(var(--primary-ton-2)); + color: hsl(var(--primary-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.35); +} + +.col-edit-pill { + display: none; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 7px; + border-radius: var(--radius-sm); + background: hsl(var(--card)); + box-shadow: inset 0 0 0 1px var(--kante-300); + font-size: var(--text-4xs); + font-weight: 500; + color: hsl(var(--muted-foreground)); + flex-shrink: 0; + cursor: pointer; +} +.col-edit-pill.is-visible { + display: inline-flex; +} +.col-edit-pill:hover { + color: hsl(var(--foreground)); + background: hsl(var(--ton-2)); +} + +.col-filename { + flex: 1 1 0%; + min-width: 0; + font-size: var(--text-2xs); + font-weight: 600; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.col-meta-badge { + display: none; + align-items: center; + height: 20px; + padding: 0 6px; + border-radius: var(--radius-sm); + background: hsl(var(--ton-2)); + box-shadow: inset 0 0 0 1px var(--kante-200); + font-family: var(--font-mono); + font-size: var(--text-4xs); + color: hsl(var(--muted-foreground)); + flex-shrink: 0; +} +.col-meta-badge.is-visible { + display: inline-flex; +} + +.col-bar .btn { + height: 26px; + padding: 0 9px; + font-size: var(--text-3xs); +} +.col-bar .btn-clear-side { + width: 26px; + height: 26px; + padding: 0; +} + +.col-sidebar-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px 0 12px; + font-size: var(--text-2xs); + font-weight: 600; + color: hsl(var(--foreground)); + border-left: 1px solid hsl(var(--border)); + overflow: hidden; + gap: 6px; +} + +.sidebar-head-left { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.sidebar-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + height: 18px; + padding: 0 6px; + border-radius: var(--radius-sm); + background: hsl(var(--ton-2)); + box-shadow: inset 0 0 0 1px var(--kante-200); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: hsl(var(--muted-foreground)); + white-space: nowrap; +} +.sidebar-count-badge.has-active { + background: hsl(var(--primary-ton-1)); + color: hsl(var(--primary-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.25); +} + +.sidebar-kbd-hint { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 5px; + border-radius: 3px; + background: hsl(var(--ton-1)); + box-shadow: inset 0 0 0 1px var(--kante-200); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + flex-shrink: 0; +} + +/* ── 3. Main Split / Inline Workspace (PRIME 1px Architectural Grid) ──── */ +.workspace { + height: calc(100vh - 82px); + height: calc(100dvh - 82px); + display: grid; + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 260px; + position: relative; + background: hsl(var(--background)); +} +body.sidebar-collapsed .workspace { + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 0px; +} + +/* Unified Inline View Mode */ +.doc-pane-unified { + display: none; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + padding: 18px 20px 96px 20px; + position: relative; + background: hsl(var(--background)); + --scrollbar-thumb: hsl(var(--border)); + --scrollbar-track: transparent; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} +body.view-inline .workspace { + grid-template-columns: minmax(0, 1fr) 260px; +} +body.view-inline.sidebar-collapsed .workspace { + grid-template-columns: minmax(0, 1fr) 0px; +} +body.view-inline .doc-pane-left, +body.view-inline .doc-pane-right, +body.view-inline .center-gutter { + display: none; +} +body.view-inline .doc-pane-unified { + display: block; +} + +.doc-pane { + height: 100%; + overflow-y: auto; + overflow-x: hidden; + padding: 18px 20px 96px 20px; + position: relative; + scroll-behavior: auto; + background: hsl(var(--background)); + --scrollbar-thumb: hsl(var(--border)); + --scrollbar-track: transparent; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} +.doc-pane-left { + padding: 18px 8px 96px 20px; + scrollbar-width: none; + -ms-overflow-style: none; +} +.doc-pane-left::-webkit-scrollbar { + display: none; +} +.doc-pane-right { + padding: 18px 20px 96px 8px; +} +@supports not (scrollbar-color: auto) { + .doc-pane-right::-webkit-scrollbar, + .doc-pane-unified::-webkit-scrollbar, + .sidebar-list::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .doc-pane-right::-webkit-scrollbar-thumb, + .doc-pane-unified::-webkit-scrollbar-thumb, + .sidebar-list::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 999px; + } + .doc-pane-right::-webkit-scrollbar-track, + .doc-pane-unified::-webkit-scrollbar-track, + .sidebar-list::-webkit-scrollbar-track { + background: var(--scrollbar-track); + } +} + +/* Drag & Drop Overlay */ +.drop-zone-overlay { + position: sticky; + top: 0; + left: 0; + right: 0; + height: calc(100dvh - 118px); + margin-bottom: calc(-100dvh + 118px); + z-index: 30; + background: hsl(var(--primary-ton-1) / 0.94); + border: 2px dashed hsl(var(--primary)); + border-radius: var(--radius-md); + display: none; + align-items: center; + justify-content: center; + pointer-events: none; +} +body.is-dragging-file .drop-zone-overlay, +.doc-pane.drag-over .drop-zone-overlay { + display: flex; +} + +.drop-box { + padding: 16px 24px; + background: hsl(var(--card)); + border-radius: var(--radius-md); + box-shadow: 0 0 0 1px hsl(var(--border)), var(--elev-3); + text-align: center; + font-size: var(--text-xs); + font-weight: 600; + color: hsl(var(--foreground)); +} + +/* ── Document Sheet & Paginated Page Cards (PRIME Geist 1px Border) ─────── */ +.doc-sheet { + max-width: 780px; + margin: 0 auto; + display: flex; + flex-direction: column; + min-height: 100%; + gap: 8px; +} +.doc-pane-left .doc-sheet { + margin: 0 0 0 auto; +} +.doc-pane-right .doc-sheet { + margin: 0 auto 0 0; +} +.doc-sheet:not(:has([data-empty-state])):not(:has(.doc-page-card)) { + background: hsl(var(--card)); + border-radius: var(--radius-sm); + box-shadow: 0 0 0 1px hsl(var(--border)); + padding: 26px 20px 26px 20px; +} +.doc-sheet:has(.doc-page-card) { + background: transparent; + box-shadow: none; + padding: 0; + gap: 20px; +} +.doc-pane-unified .doc-sheet { + max-width: 840px; +} + +.doc-page-card { + background: hsl(var(--card)); + border-radius: var(--radius-sm); + box-shadow: 0 0 0 1px hsl(var(--border)), var(--elev-2); + display: flex; + flex-direction: column; +} + +.doc-page-header { + height: 30px; + padding: 0 20px; + border-bottom: 1px solid var(--kante-100); + background: hsl(var(--ton-1) / 0.55); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-family: var(--font-mono); + font-size: 10.5px; + color: hsl(var(--muted-foreground)); + user-select: none; + flex-shrink: 0; +} + +.page-head-left { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.page-num-label { + font-weight: 600; + color: hsl(var(--foreground)); +} + +.page-sec-range { + color: hsl(var(--muted-foreground)); + opacity: 0.85; +} + +.page-change-pill { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + background: hsl(var(--ton-2)); + color: hsl(var(--muted-foreground)); +} +.page-change-pill.has-changes { + background: hsl(var(--primary-ton-1)); + color: hsl(var(--primary-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.22); +} + +.doc-page-body { + padding: 20px 20px 24px 20px; + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +/* Stream Mode (Endlos-Textfluss): Collapse Page Cards into Continuous Sheet */ +body.view-stream .doc-sheet:has(.doc-page-card) { + background: hsl(var(--card)); + border-radius: var(--radius-sm); + box-shadow: 0 0 0 1px hsl(var(--border)); + padding: 24px 20px; + gap: 6px; +} +body.view-stream .doc-page-card { + background: transparent; + box-shadow: none; + border-radius: 0; + min-height: 0 !important; +} +body.view-stream .doc-page-header { + display: none; +} +body.view-stream .doc-page-body { + padding: 0; + gap: 6px; +} + +/* Centered Zero-Friction Launchpad Overlay (PRIME Geist Architecture) */ +body:not(.is-comparing) .col-bars { + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) !important; +} +body:not(.is-comparing) .col-sidebar-head { + display: none !important; +} +body:not(.is-comparing) .workspace { + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) !important; +} +body:not(.is-comparing).view-inline .workspace { + grid-template-columns: minmax(0, 1fr) !important; +} +body:not(.is-comparing) .sidebar-panel { + display: none !important; +} +body:not(.is-comparing) #btn-export-menu { + background: hsl(var(--ton-2)); + color: hsl(var(--muted-foreground)); + box-shadow: inset 0 0 0 1px var(--kante-200); + pointer-events: none; + opacity: 0.45; +} +body:not(.is-comparing) #btn-only-changes, +body:not(.is-comparing) #btn-filter-hf, +body:not(.is-comparing) #search-box-wrap, +body:not(.is-comparing) #btn-prev, +body:not(.is-comparing) #btn-next, +body:not(.is-comparing) #btn-toggle-sidebar { + opacity: 0.4; + pointer-events: none; +} + +/* Subtle schematic background page lines when empty */ +.ghost-sheet { + display: flex; + flex-direction: column; + gap: 14px; + padding: 12px 8px; + opacity: 0.35; + pointer-events: none; + user-select: none; +} +.ghost-block { + display: flex; + flex-direction: column; + gap: 7px; + padding: 8px 12px; + border-left: 2px solid hsl(var(--border)); +} +.ghost-line { + height: 8px; + border-radius: 2px; + background: hsl(var(--ton-1)); +} +.ghost-line.h { + height: 11px; + width: 38%; + background: hsl(var(--ton-2)); + margin-bottom: 2px; +} + +.empty-launchpad { + position: absolute; + inset: 0; + z-index: 25; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: hsl(var(--background) / 0.65); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + overflow-y: auto; +} +body.is-comparing .empty-launchpad { + display: none !important; +} + +.launchpad-card { + width: 100%; + max-width: 640px; + margin: auto; + background: hsl(var(--card)); + border-radius: var(--radius-xl); + box-shadow: 0 0 0 1px hsl(var(--border)), var(--elev-4); + display: flex; + flex-direction: column; + overflow: hidden; + user-select: none; +} + +.launchpad-head { + padding: 24px 28px 20px 28px; + border-bottom: 1px solid hsl(var(--border)); + text-align: left; +} + +.launchpad-title { + font-size: var(--text-base); + font-weight: 600; + letter-spacing: -0.02em; + color: hsl(var(--foreground)); +} + +.launchpad-sub { + margin-top: 4px; + font-size: var(--text-2xs); + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} + +.launchpad-grid { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + border-bottom: 1px solid hsl(var(--border)); +} + +.launchpad-slot { + padding: 20px 24px; + background: hsl(var(--card)); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + text-align: left; + cursor: pointer; + transition: background-color 80ms ease-out; +} +.launchpad-slot:first-child { + border-right: 1px solid hsl(var(--border)); +} +.launchpad-slot:hover, +.launchpad-slot.drag-over { + background: hsl(var(--ton-1)); +} + +.lp-slot-top { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 6px; +} + +.lp-slot-title { + font-size: var(--text-xs); + font-weight: 600; + color: hsl(var(--foreground)); + word-break: break-word; +} + +.lp-slot-hint { + font-size: var(--text-3xs); + line-height: 1.4; + color: hsl(var(--muted-foreground)); +} +.launchpad-slot.is-loaded .lp-slot-hint { + color: hsl(var(--success-ink)); + font-family: var(--font-mono); + font-weight: 500; +} + +.launchpad-slot .btn { + margin-top: 6px; + width: 100%; + height: 32px; +} + +.launchpad-secondary { + padding: 14px 24px; + background: hsl(var(--ton-1)); + border-bottom: 1px solid hsl(var(--border)); + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} + +.launchpad-footer { + padding: 12px 24px; + width: 100%; + background: hsl(var(--card)); + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; +} + +.launchpad-steps { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-family: var(--font-mono); + font-size: var(--text-4xs); + color: hsl(var(--muted-foreground)); +} +.lp-step { + display: inline-flex; + align-items: center; + gap: 4px; +} +.lp-step-num { + color: hsl(var(--foreground)); + font-weight: 600; +} +.lp-sep { + color: hsl(var(--trenner)); +} + +.launchpad-trust { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-family: var(--font-mono); + font-size: var(--text-4xs); + color: hsl(var(--muted-foreground)); +} +.lp-link { + background: transparent; + border: 0; + padding: 0; + font: inherit; + color: hsl(var(--muted-foreground)); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} +.lp-link:hover { + color: hsl(var(--foreground)); +} + +.empty-state { + margin: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 36px 30px; + max-width: 490px; + width: 100%; + background: hsl(var(--card)); + border-radius: var(--radius-xl); + box-shadow: 0 0 0 1px var(--kante-200), var(--elev-2); + user-select: none; +} +.empty-state-sm { + padding: 36px 18px; + max-width: 250px; + background: transparent; + box-shadow: none; +} +.empty-state-sm .empty-glyph { + width: 26px; + height: 26px; + stroke: hsl(var(--muted-foreground)); + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + opacity: 0.6; + margin-bottom: 10px; +} +.empty-state-sm .empty-title { + font-size: var(--text-xs); + font-weight: 700; + color: hsl(var(--foreground)); +} +.empty-state-sm .empty-desc { + margin-top: 6px; + font-size: var(--text-2xs); + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} + +.diff-row-anchor { + position: relative; + display: flex; + flex-direction: column; + justify-content: stretch; +} +.diff-row-anchor:hover, +.diff-row-anchor:has(.clause.is-hovered), +.diff-row-anchor:has(.clause.is-active), +.diff-row-anchor:focus-within { + z-index: 15; +} + +.clause.deferred-block { + content-visibility: auto; + contain-intrinsic-size: auto none auto 48px; +} +.clause.deferred-block.is-hovered, +.clause.deferred-block.is-active, +.clause.deferred-block:focus-within { + content-visibility: visible; +} + +/* Structured Document Typography & Hanging-Indent Lists (PRIME Geist) */ +.clause { + flex: 1; + position: relative; + padding: 11px 14px; + border-left: 2px solid transparent; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + font-size: var(--text-chat-label); + line-height: 1.6; + color: hsl(var(--foreground)); + cursor: pointer; + transition: background-color 80ms ease-out, border-color 80ms ease-out; + text-wrap: pretty; +} + +.clause.is-heading, +.clause.kind-h2 { + font-weight: 700; + font-size: var(--text-sm); + line-height: 1.4; + padding-top: 14px; + padding-bottom: 8px; + margin-top: 4px; + letter-spacing: -0.015em; + color: hsl(var(--foreground)); + text-wrap: balance; +} + +.clause.kind-h1 { + font-weight: 700; + font-size: var(--text-base); + line-height: 1.35; + padding: 12px 14px 11px 14px; + margin-bottom: 2px; + border-bottom: 1px solid var(--kante-200); + letter-spacing: -0.022em; + color: hsl(var(--foreground)); + text-wrap: balance; +} + +.clause.kind-h3 { + font-weight: 600; + font-size: var(--text-chat-label); + line-height: 1.45; + padding: 10px 14px 5px 14px; + margin-top: 2px; + letter-spacing: -0.01em; + color: hsl(var(--foreground)); + text-wrap: balance; +} + +.clause.has-marker { + display: grid; + grid-template-columns: minmax(26px, auto) 1fr; + column-gap: 8px; + align-items: baseline; +} + +.clause.kind-bullet.has-marker { + grid-template-columns: 14px 1fr; + column-gap: 7px; + padding-left: 24px; + padding-top: 7px; + padding-bottom: 7px; +} + +.clause-marker { + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + font-variant-numeric: tabular-nums; + white-space: nowrap; + user-select: text; +} + +.clause.kind-bullet .clause-marker { + font-family: var(--font-sans); + font-size: 0.95rem; + line-height: 1; + color: hsl(var(--foreground) / 0.75); + text-align: center; +} + +.clause-body { + min-width: 0; + text-wrap: pretty; +} + +.clause.kind-hf { + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.4; + padding: 6px 14px; + color: hsl(var(--muted-foreground)); + opacity: 0.82; + border-bottom: 1px dashed var(--kante-200); +} + +.clause.is-editing { + display: block !important; +} + +.clause.state-equal { + background: transparent; + border-left-color: transparent; +} + +.clause.state-modified { + border-left-color: hsl(var(--primary)); + background: transparent; +} + +.clause.state-deleted { + border-left-color: hsl(var(--destructive)); + background: hsl(var(--destructive-ton-1)); +} + +.clause.state-inserted { + border-left-color: hsl(var(--success)); + background: hsl(var(--success-ton-1)); +} + +.clause.state-placeholder { + display: flex; + align-items: center; + color: hsl(var(--muted-foreground)); + font-size: var(--text-2xs); + user-select: none; + opacity: 0.55; +} +.clause.state-placeholder::before { + content: ""; + flex: 1; + height: 1px; + border-top: 1px dashed hsl(var(--border)); +} + +/* Synchronized Mouse-Over Highlight across both documents */ +.clause.is-hovered { + background: hsl(var(--ton-1)); + border-left-color: hsl(var(--foreground)); +} +.clause.state-modified.is-hovered { + background: hsl(var(--primary-ton-1)); + border-left-color: hsl(var(--primary)); +} +.clause.state-deleted.is-hovered { + background: hsl(var(--destructive-ton-2) / 0.65); +} +.clause.state-inserted.is-hovered { + background: hsl(var(--success-ton-2) / 0.65); +} + +.clause.is-active { + background: hsl(var(--ton-1)); + box-shadow: inset 0 0 0 1px var(--kante-300); +} +.clause.state-modified.is-active { + background: hsl(var(--primary-ton-1)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.35); +} +.clause.state-deleted.is-active { + background: hsl(var(--destructive-ton-1)); + box-shadow: inset 0 0 0 1px hsl(var(--destructive) / 0.38); +} +.clause.state-inserted.is-active { + background: hsl(var(--success-ton-1)); + box-shadow: inset 0 0 0 1px hsl(var(--success) / 0.38); +} +@keyframes clauseJumpPulse { + 0% { box-shadow: inset 0 0 0 2px hsl(var(--ring)); } + 100% { box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.35); } +} +.clause.is-jump-flash { + animation: clauseJumpPulse 650ms ease-out; +} + +/* Right-Column Clause Action Bar — Centered in the 41px inter-paragraph whitespace corridor */ +.clause-actions { + position: absolute; + top: -16px; + bottom: auto; + right: 10px; + padding: 1px; + border-radius: var(--radius-md); + background: hsl(var(--card) / 0.94); + box-shadow: 0 0 0 1px var(--kante-300), var(--elev-2); + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + pointer-events: none; + transform: translateY(2px); + transition: opacity 80ms ease-out, transform 80ms ease-out; + z-index: 20; +} +.clause-actions::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 10px; +} +/* Performance: Only activate GPU backdrop-filter on the single hovered/active clause */ +.clause.is-hovered .clause-actions, +.clause.is-active .clause-actions, +.clause-actions:focus-within { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + backdrop-filter: saturate(180%) blur(10px); + -webkit-backdrop-filter: saturate(180%) blur(10px); +} +@media (hover: none) { + .clause.is-active .clause-actions { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + } +} +.clause-action-btn { + height: 22px; + padding: 0 8px; + border-radius: var(--radius-sm); + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + box-shadow: none; + font-family: var(--font-sans); + font-size: var(--text-4xs); + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + transition: color 80ms ease-out, background-color 80ms ease-out; +} +.clause-action-btn:hover { + background: hsl(var(--ton-2)); + color: hsl(var(--foreground)); +} +.clause-action-btn.is-revert:hover { + color: hsl(var(--primary-ink)); +} + +.clause.is-editing { + padding: 10px 14px; + background: hsl(var(--card)) !important; + box-shadow: inset 0 0 0 2px hsl(var(--ring)) !important; + cursor: text; +} +.clause-inline-editor { + width: 100%; + border: 0; + outline: 0; + padding: 0; + margin: 0; + background: transparent; + color: hsl(var(--foreground)); + font-family: var(--font-sans); + font-size: inherit; + font-weight: inherit; + line-height: inherit; + resize: none; + overflow: hidden; + display: block; +} +.clause-editor-bar { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: var(--text-3xs); + color: hsl(var(--muted-foreground)); + user-select: none; +} +.clause-editor-btns { + display: inline-flex; + align-items: center; + gap: 6px; +} +.clause-editor-btn { + height: 28px; + padding: 0 11px; + border-radius: var(--radius-sm); + border: 0; + font-family: var(--font-sans); + font-size: var(--text-3xs); + font-weight: 600; + cursor: pointer; +} +.clause-editor-btn.save { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} +.clause-editor-btn.cancel { + background: hsl(var(--ton-2)); + color: hsl(var(--foreground)); +} + +/* Instant Floating UI Tooltip */ +.ui-tooltip { + position: fixed; + z-index: 9999; + max-width: 320px; + padding: 6px 10px; + border-radius: var(--radius-md); + background: hsl(var(--foreground)); + color: hsl(var(--background)); + box-shadow: var(--elev-3); + font-family: var(--font-sans); + font-size: var(--text-3xs); + font-weight: 500; + line-height: 1.4; + pointer-events: none; + opacity: 0; + transform: translateY(2px); + transition: opacity 90ms ease-out, transform 90ms ease-out; +} +.ui-tooltip.is-visible { + opacity: 1; + transform: translateY(0); +} + +/* Help / Guide Dialog Grid */ +dialog.prime-dialog.dialog-md { + width: min(740px, 94vw); +} +.help-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.help-card { + padding: 14px 16px; + border-radius: var(--radius-lg); + background: hsl(var(--ton-1)); + box-shadow: inset 0 0 0 1px var(--kante-200); + display: flex; + flex-direction: column; + gap: 6px; +} +.help-card-title { + font-size: var(--text-xs); + font-weight: 700; + color: hsl(var(--foreground)); + display: flex; + align-items: center; + gap: 6px; +} +.help-card-desc { + font-size: var(--text-2xs); + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} +@media (max-width: 640px) { + .help-grid { + grid-template-columns: 1fr; + } +} + +.dropdown-divider { + height: 1px; + margin: 4px 4px; + background: hsl(var(--border)); +} + +del.tok-del { + padding: 1px 4px; + margin: 0 0.5px; + border-radius: var(--radius-sm); + background: hsl(var(--destructive-ton-2)); + color: hsl(var(--destructive-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--destructive) / 0.22); + text-decoration: line-through; + font-weight: 500; +} + +ins.tok-ins { + padding: 1px 4px; + margin: 0 0.5px; + border-radius: var(--radius-sm); + background: hsl(var(--success-ton-2)); + color: hsl(var(--success-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--success) / 0.25); + text-decoration: none; + font-weight: 600; +} + +mark.tok-search { + background: hsl(var(--warning-ton-2)); + color: hsl(var(--foreground)); + border-radius: var(--radius-sm); + padding: 0 3px; + font-weight: 600; +} + +body.only-changes .diff-row-anchor[data-type="equal"] { + display: none; +} +body.only-changes .doc-page-card:not(:has(.diff-row-anchor:not([data-type="equal"]))) { + display: none; +} + +/* ── 28px Symmetric Center Gutter ──────────────────────────────────────── */ +.center-gutter { + height: 100%; + background: hsl(var(--background)); + position: relative; + z-index: 5; + overflow: visible; + clip-path: inset(0 -8px 0 -8px); + user-select: none; +} + +#connector-svg { + position: absolute; + top: 0; + bottom: 0; + left: -8px; + width: calc(100% + 16px); + height: 100%; + pointer-events: none; + z-index: 2; +} + +.minimap-rail { + position: absolute; + top: 10px; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + width: 5px; + background: transparent; + z-index: 1; +} + +.minimap-tick { + position: absolute; + left: 0; + width: 5px; + min-height: 5px; + border-radius: 2px; + cursor: pointer; + opacity: 0.8; +} +.minimap-tick:hover, +.minimap-tick.is-active { + opacity: 1; + transform: scaleX(1.7); +} +.minimap-tick.mod { background: hsl(var(--primary)); } +.minimap-tick.ins { background: hsl(var(--success)); } +.minimap-tick.del { background: hsl(var(--destructive)); } + +/* ── Frosted Glass Floating Action Bar (Bottom-Mid HUD) ───────────────── */ +.floating-action-bar { + position: fixed; + bottom: 18px; + left: calc(50% - 130px); + transform: translateX(-50%); + z-index: 35; + height: 40px; + padding: 0 6px; + border-radius: var(--radius-xl); + background: hsl(0 0% 100% / 0.68); + backdrop-filter: saturate(190%) blur(18px); + -webkit-backdrop-filter: saturate(190%) blur(18px); + box-shadow: + 0 0 0 1px hsl(0 0% 0% / 0.09), + inset 0 1px 0 0 hsl(0 0% 100% / 0.85), + 0 12px 32px -4px rgb(0 0 0 / 0.14), + 0 4px 10px -2px rgb(0 0 0 / 0.07); + display: inline-flex; + align-items: center; + gap: 4px; + user-select: none; + transition: + left 150ms ease-out, + opacity 160ms ease-out, + transform 160ms ease-out, + background-color 120ms ease-out; +} +.dark .floating-action-bar { + background: hsl(0 0% 9% / 0.64); + box-shadow: + 0 0 0 1px hsl(0 0% 100% / 0.13), + inset 0 1px 0 0 hsl(0 0% 100% / 0.08), + 0 16px 36px -4px rgb(0 0 0 / 0.68), + 0 4px 12px -2px rgb(0 0 0 / 0.48); +} +body.sidebar-collapsed .floating-action-bar { + left: 50%; +} +body:not(.is-comparing) .floating-action-bar { + left: 50%; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 10px); +} + +.dock-cluster { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.dock-stepper { + padding: 2px; + border-radius: var(--radius-md); + background: hsl(var(--foreground) / 0.045); + box-shadow: inset 0 0 0 1px var(--kante-100); +} + +.dock-divider { + width: 1px; + height: 18px; + margin: 0 3px; + background: var(--kante-200); + flex-shrink: 0; +} + +.dock-icon-btn { + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 80ms ease-out, color 80ms ease-out, opacity 80ms ease-out; +} +.dock-icon-btn:hover:not(:disabled) { + background: hsl(var(--foreground) / 0.08); +} +.dock-icon-btn:active:not(:disabled) { + background: hsl(var(--foreground) / 0.13); +} +.dock-icon-btn:disabled { + opacity: 0.32; + cursor: default; +} + +.dock-step-badge { + height: 24px; + min-width: 48px; + padding: 0 7px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 11px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + white-space: nowrap; + transition: background-color 80ms ease-out, color 80ms ease-out; +} +.dock-step-badge:hover { + background: hsl(var(--foreground) / 0.07); + color: hsl(var(--foreground)); +} +.dock-step-badge.has-active { + color: hsl(var(--primary-ink)); + background: hsl(var(--primary) / 0.14); +} + +.dock-btn { + height: 28px; + padding: 0 10px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: hsl(var(--muted-foreground)); + font-family: var(--font-sans); + font-size: var(--text-3xs); + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + white-space: nowrap; + transition: background-color 80ms ease-out, color 80ms ease-out, box-shadow 80ms ease-out; +} +.dock-btn:hover { + background: hsl(var(--foreground) / 0.07); + color: hsl(var(--foreground)); +} +.dock-btn[aria-pressed="true"] { + background: hsl(var(--foreground) / 0.09); + color: hsl(var(--foreground)); + box-shadow: inset 0 0 0 1px var(--kante-200); +} +#btn-only-changes.dock-btn[aria-pressed="true"], +#btn-filter-hf.dock-btn[aria-pressed="true"] { + background: hsl(var(--primary) / 0.16); + color: hsl(var(--primary-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.32); +} + +.dock-count-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: hsl(var(--foreground) / 0.08); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + line-height: 1; +} +#btn-filter-hf.dock-btn[aria-pressed="true"] .dock-count-pill { + background: hsl(var(--primary) / 0.22); + color: hsl(var(--primary-ink)); +} + +/* ── AAA 10/10 Right Side Rail (Change Navigator & Delta Inspector) ────── */ +.sidebar-panel { + height: 100%; + background: hsl(var(--background)); + border-left: 1px solid hsl(var(--border)); + display: flex; + flex-direction: column; + overflow: hidden; +} +body.sidebar-collapsed .sidebar-panel { + border-left: 0; +} + +.sidebar-filter-bar { + display: flex; + align-items: center; + gap: 2px; + padding: 6px 8px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--ton-1) / 0.5); + flex-shrink: 0; +} +body:not(.is-comparing) .sidebar-filter-bar { + display: none !important; +} + +.sidebar-filter-btn { + flex: 1 1 auto; + min-width: 0; + height: 24px; + padding: 0 5px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: hsl(var(--muted-foreground)); + font-family: var(--font-sans); + font-size: 10.5px; + font-weight: 500; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + cursor: pointer; + white-space: nowrap; + transition: background-color 80ms ease-out, color 80ms ease-out, box-shadow 80ms ease-out; +} +.sidebar-filter-btn:hover { + color: hsl(var(--foreground)); + background: hsl(var(--ton-2) / 0.65); +} +.sidebar-filter-btn.is-active { + background: hsl(var(--card)); + color: hsl(var(--foreground)); + font-weight: 600; + box-shadow: 0 0 0 1px var(--kante-200), var(--elev-1); +} +.sidebar-filter-btn.is-zero:not(.is-active) { + opacity: 0.42; +} + +.sidebar-filter-count { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: hsl(var(--muted-foreground)); +} +.sidebar-filter-count.mod { color: hsl(var(--primary-ink)); } +.sidebar-filter-count.ins { color: hsl(var(--success-ink)); } +.sidebar-filter-count.del { color: hsl(var(--destructive-ink)); } + +.sidebar-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + --scrollbar-thumb: hsl(var(--border)); + --scrollbar-track: transparent; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +.sidebar-footer { + height: 34px; + padding: 0 12px; + border-top: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: space-between; + font-family: var(--font-mono); + font-size: var(--text-4xs); + color: hsl(var(--muted-foreground)); + user-select: none; + flex-shrink: 0; +} + +.privacy-status { + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; + background: transparent; + border: 0; + font-family: var(--font-mono); + font-size: var(--text-4xs); + color: hsl(var(--muted-foreground)); +} +.privacy-status:hover { + color: hsl(var(--foreground)); +} + +.nav-item { + padding: 8px 10px; + border-radius: var(--radius-sm); + border-left: 2.5px solid transparent; + background: hsl(var(--card)); + box-shadow: 0 0 0 1px var(--kante-100); + cursor: pointer; + font-size: var(--text-2xs); + line-height: 1.45; + color: hsl(var(--foreground)); + transition: background-color 80ms ease-out, box-shadow 80ms ease-out, border-color 80ms ease-out; + outline: none; +} +.nav-item.mod { border-left-color: hsl(var(--primary)); } +.nav-item.ins { border-left-color: hsl(var(--success)); } +.nav-item.del { border-left-color: hsl(var(--destructive)); } + +.nav-item:hover, +.nav-item.is-hovered, +.nav-item:focus-visible { + background: hsl(var(--ton-1)); + box-shadow: 0 0 0 1px var(--kante-300); +} +.nav-item.is-active { + background: hsl(var(--primary-ton-1)); + box-shadow: 0 0 0 1px hsl(var(--primary) / 0.45); +} +.nav-item.ins.is-active { + background: hsl(var(--success-ton-1)); + box-shadow: 0 0 0 1px hsl(var(--success) / 0.45); +} +.nav-item.del.is-active { + background: hsl(var(--destructive-ton-1)); + box-shadow: 0 0 0 1px hsl(var(--destructive) / 0.45); +} + +.nav-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 5px; +} + +.nav-item-loc { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +.nav-item-idx { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 4px; + border-radius: 3px; + background: hsl(var(--ton-2)); + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + flex-shrink: 0; +} +.nav-item.is-active .nav-item-idx { + background: hsl(var(--foreground)); + color: hsl(var(--background)); +} + +.nav-item-ref { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-item-page { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + white-space: nowrap; + flex-shrink: 0; +} + +.nav-item-right { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.nav-item-actions { + display: inline-flex; + align-items: center; + gap: 1px; + opacity: 0; + pointer-events: none; + transition: opacity 80ms ease-out; +} +.nav-item:hover .nav-item-actions, +.nav-item.is-hovered .nav-item-actions, +.nav-item.is-active .nav-item-actions, +.nav-item:focus-within .nav-item-actions { + opacity: 1; + pointer-events: auto; +} + +.nav-item-act-btn { + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 3px; + background: transparent; + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 80ms ease-out, color 80ms ease-out; +} +.nav-item-act-btn:hover { + background: hsl(var(--ton-2)); + color: hsl(var(--foreground)); +} +.nav-item-act-btn.is-revert:hover { + color: hsl(var(--primary-ink)); +} + +.nav-item-badge { + display: inline-flex; + align-items: center; + height: 17px; + padding: 0 5px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 600; + letter-spacing: -0.01em; +} +.nav-item-badge.mod { + background: hsl(var(--primary-ton-1)); + color: hsl(var(--primary-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.2); +} +.nav-item-badge.ins { + background: hsl(var(--success-ton-1)); + color: hsl(var(--success-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--success) / 0.22); +} +.nav-item-badge.del { + background: hsl(var(--destructive-ton-1)); + color: hsl(var(--destructive-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--destructive) / 0.22); +} + +.nav-item-deltas { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11.5px; + line-height: 1.42; +} + +.nav-delta-row { + display: flex; + align-items: baseline; + gap: 4px; + flex-wrap: wrap; + word-break: break-word; +} + +.nav-delta-arrow { + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); + font-size: 10px; + user-select: none; + flex-shrink: 0; +} + +.nav-delta-more { + font-family: var(--font-mono); + font-size: 10px; + color: hsl(var(--muted-foreground)); + margin-top: 1px; +} + +.nav-item-snippet { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + font-size: 11.5px; + line-height: 1.42; +} + +/* ── Dialogs ───────────────────────────────────────────────────────────── */ +dialog.prime-dialog { + margin: auto; + width: min(900px, 92vw); + background: hsl(var(--card)); + color: hsl(var(--foreground)); + border: 0; + border-radius: var(--radius-xl); + box-shadow: 0 0 0 1px hsl(var(--border)), var(--elev-4); + padding: 0; +} +dialog.prime-dialog.dialog-sm { + width: min(560px, 92vw); +} +dialog.prime-dialog::backdrop { + background: var(--overlay); + backdrop-filter: blur(2px); +} +.dialog-header { + height: 48px; + padding: 0 18px; + border-bottom: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: space-between; + font-size: var(--text-xs); + font-weight: 700; +} +.dialog-body { + padding: 18px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.dialog-body-single { + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + font-size: var(--text-2xs); + line-height: 1.6; + color: hsl(var(--muted-foreground)); +} +.dialog-col { + display: flex; + flex-direction: column; + gap: 8px; + font-size: var(--text-2xs); + font-weight: 600; +} +.dialog-textarea { + width: 100%; + height: min(360px, 55dvh); + padding: 10px 12px; + background: hsl(var(--card)); + color: hsl(var(--foreground)); + border: 0; + border-radius: var(--radius-md); + box-shadow: inset 0 0 0 1px hsl(var(--input)); + font-family: var(--font-mono); + font-size: var(--text-2xs); + line-height: 1.55; + resize: vertical; +} +.dialog-textarea:focus { + outline: none; + box-shadow: inset 0 0 0 2px hsl(var(--ring)); +} +.dialog-footer { + height: 52px; + padding: 0 18px; + background: hsl(var(--ton-1)); + border-top: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +/* ── Responsive Tiers: Desktop, Laptop, Tablet, Mobile ───────────────── */ +.header-right-wrap { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +@media (max-width: 1600px) { + .col-edit-pill, + #meta-right { + display: none !important; + } +} + +@media (max-width: 1380px) { + .search-box { + width: 150px; + } + .search-box:focus-within { + width: 180px; + } + .col-meta-badge { + display: none !important; + } +} + +@media (max-width: 1180px) { + #i18n-btn-text, + #i18n-btn-clear, + #i18n-btn-help, + #i18n-changes-label, + .kbd-hint, + #i18n-upload-left, + #i18n-upload-right { + display: none !important; + } + #btn-paste-modal, + #btn-clear, + #btn-help { + width: 30px; + padding: 0; + } + #btn-upload-left, + #btn-upload-right { + width: 26px; + padding: 0 !important; + } + .search-box { + width: 135px; + } + .search-box:focus-within { + width: 165px; + } +} + +@media (max-width: 960px) { + /* On Tablet: Sidebar becomes a slide-over drawer on the right */ + .seg-btn svg, + .header-bar .divider-v { + display: none !important; + } + .floating-action-bar { + left: 50%; + } + .search-box { + width: 120px; + } + .search-box:focus-within { + width: 145px; + } + .col-bars { + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) !important; + } + .col-sidebar-head { + display: none !important; + } + .workspace, + body.sidebar-collapsed .workspace { + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) !important; + } + body.view-inline .workspace, + body.view-inline.sidebar-collapsed .workspace { + grid-template-columns: minmax(0, 1fr) !important; + } + .sidebar-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(300px, 84vw); + z-index: 40; + border-left: 1px solid hsl(var(--border)); + box-shadow: var(--elev-4); + transition: transform 150ms ease-out; + } + body.sidebar-collapsed .sidebar-panel { + transform: translateX(105%); + pointer-events: none; + } + .doc-pane-unified { + padding: 16px 18px 84px 18px; + } + .doc-pane-left { + padding: 16px 8px 84px 18px; + } + .doc-pane-right { + padding: 16px 18px 84px 8px; + } +} + +/* Mobile-only elements hidden on Desktop/Tablet */ +.mobile-bottom-bar, +.mobile-sheet-header, +.mobile-sheet-backdrop { + display: none; +} + +@media (max-width: 720px) { + /* ── iOS-Native Mobile Experience (<= 720px) ─────────────────────────── */ + .floating-action-bar { + display: none !important; + } + .header-bar { + display: grid; + grid-template-columns: 1fr auto; + height: auto; + padding: 0; + gap: 0; + background: hsl(var(--card) / 0.88); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + } + .header-right-wrap { + display: contents; + } + .header-brand { + grid-row: 1; + grid-column: 1; + height: 46px; + padding: 0 0 0 14px; + gap: 8px; + min-width: 0; + } + .header-brand .divider-v { + display: none; + } + .app-title { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.025em; + } + .stat-summary { + height: 24px; + padding: 0 9px; + border-radius: 999px; + background: hsl(var(--ton-1)); + box-shadow: inset 0 0 0 1px var(--kante-200); + font-size: 11px; + gap: 5px; + } + .header-tools { + grid-row: 1; + grid-column: 2; + height: 46px; + padding: 0 12px 0 4px; + gap: 6px; + } + .header-tools .btn { + height: 32px; + min-width: 32px; + padding: 0 10px; + border-radius: 8px; + font-size: 12px; + } + .header-tools .btn-icon { + width: 32px; + padding: 0; + } + /* Hide desktop-only top controls on mobile (handled in iOS Bottom Bar) */ + #btn-toggle-sidebar, + #btn-sync-scroll, + #btn-only-changes, + #btn-prev, + #btn-next, + .kbd-hint, + #i18n-changes-label, + #i18n-btn-help, + #i18n-btn-text, + #i18n-btn-clear, + .col-edit-pill, + .btn-clear-side { + display: none !important; + } + body:not(.has-docs) #btn-clear { + display: none !important; + } + #btn-paste-modal, + #btn-clear, + #btn-help { + width: 32px; + padding: 0 !important; + } + + /* Row 2: iOS Segmented Control + iOS Search Bar */ + .header-nav { + grid-row: 2; + grid-column: 1 / -1; + height: 44px; + padding: 0 12px 6px 12px; + background: transparent; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + } + .header-nav .divider-v { + display: none; + } + .header-nav .seg-group { + height: 34px; + padding: 2px; + border-radius: 9px; + background: hsl(var(--ton-2)); + box-shadow: none; + flex: 1.15; + display: flex; + } + .header-nav .seg-btn { + flex: 1; + height: 30px; + padding: 0 8px; + border-radius: 7px; + font-size: 12px; + justify-content: center; + } + .header-nav .search-box { + flex: 1; + width: auto; + min-width: 105px; + height: 34px; + border-radius: 9px; + background: hsl(var(--ton-2)); + box-shadow: none; + padding: 0 10px; + } + .header-nav .search-box:focus-within { + width: auto; + background: hsl(var(--card)); + box-shadow: inset 0 0 0 1.5px hsl(var(--ring)); + } + /* 16px font-size prevents iOS Safari auto-zoom on input focus */ + .header-nav .search-box input { + font-size: 16px; + } + + /* Document Bar (.col-bars) on Mobile */ + body.view-inline:not(.has-docs) .col-bars { + height: 0 !important; + min-height: 0 !important; + border-bottom: 0 !important; + overflow: visible !important; + } + body.view-inline:not(.has-docs) .col-bar { + height: 0 !important; + padding: 0 !important; + border: 0 !important; + } + body.view-inline:not(.has-docs) .col-bar-left, + body.view-inline:not(.has-docs) #btn-swap-docs, + body.view-inline:not(.has-docs) .col-bar-right .col-title-wrap, + body.view-inline:not(.has-docs) #btn-upload-right, + body.view-inline:not(.has-docs) #btn-clear-right { + display: none !important; + } + .col-bars { + height: 38px !important; + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) !important; + background: hsl(var(--ton-1)); + } + .col-bar { + height: 38px; + padding: 0 8px; + gap: 4px; + overflow: visible; + } + .col-meta-badge, + .col-bar .history-group, + #i18n-upload-left, + #i18n-upload-right, + #btn-export-menu { + display: none !important; + } + .col-role-pill { + height: 19px; + padding: 0 5px; + border-radius: 4px; + font-size: 9.5px; + } + #btn-upload-left, + #btn-upload-right { + width: 26px; + height: 26px !important; + border-radius: 6px; + padding: 0 !important; + } + .col-filename { + font-size: 11px; + } + + /* Mobile Workspace Heights (accounting for iOS Bottom Toolbar) */ + .center-gutter { + display: none !important; + } + .workspace, + body.sidebar-collapsed .workspace { + height: calc(100dvh - 128px); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; + } + body.view-inline .workspace, + body.view-inline.sidebar-collapsed .workspace { + height: calc(100dvh - 128px); + grid-template-columns: 1fr !important; + } + body.view-inline:not(.has-docs) .workspace, + body.view-inline:not(.has-docs).sidebar-collapsed .workspace { + height: calc(100dvh - 90px); + } + + /* Native iOS Document Reading Typography & Spacing */ + .doc-pane { + padding: 10px 8px calc(84px + env(safe-area-inset-bottom)) 8px; + } + .doc-pane .clause { + padding: 8px 9px; + font-size: 12.5px; + line-height: 1.48; + hyphens: auto; + border-radius: 0 6px 6px 0; + } + .doc-pane-unified { + padding: 14px 14px calc(92px + env(safe-area-inset-bottom)) 14px; + } + .doc-pane-unified .clause { + padding: 10px 13px; + font-size: 15px; + line-height: 1.55; + border-radius: 0 8px 8px 0; + margin-bottom: 2px; + } + .doc-pane-unified .clause.is-heading { + font-size: 16px; + padding-top: 14px; + padding-bottom: 4px; + } + + /* Touch-Friendly Inline Clause Actions on Mobile (revealed on tap below clause) */ + .doc-sheet:not(:has([data-empty-state])) { + padding: 12px 10px 18px 10px; + border-radius: 14px; + } + .clause-actions { + position: static; + bottom: auto; + right: auto; + display: none; + width: 100%; + margin-top: 8px; + padding: 8px 0 0 0; + border-radius: 0; + background: transparent; + box-shadow: none; + border-top: 1px solid var(--kante-200); + opacity: 1; + pointer-events: auto; + transform: none; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + gap: 8px; + } + .clause-actions::after { + display: none; + } + .clause.is-active .clause-actions { + display: flex; + } + .clause-action-btn { + height: 30px; + padding: 0 12px; + border-radius: 999px; + background: hsl(var(--card)); + box-shadow: 0 0 0 1px var(--kante-300), var(--elev-1); + font-size: 12px; + font-weight: 600; + } + .clause-inline-editor { + font-size: 16px !important; + line-height: 1.5 !important; + } + .clause-editor-bar { + margin-top: 8px; + padding-top: 8px; + font-size: 11px; + } + .clause-editor-btn { + height: 30px; + padding: 0 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + } + + /* iOS Grouped Cards Launchpad on Mobile */ + .empty-launchpad { + padding: 12px 12px calc(76px + env(safe-area-inset-bottom)) 12px; + align-items: flex-start; + } + .launchpad-card { + padding: 22px 18px 18px 18px; + border-radius: 16px; + margin: 0 auto; + } + .launchpad-title { + font-size: 18px; + } + .launchpad-sub { + font-size: 13px; + } + .launchpad-grid { + grid-template-columns: 1fr; + gap: 10px; + margin-top: 16px; + } + .launchpad-arrow { + display: none; + } + .launchpad-slot { + padding: 14px 14px; + border-radius: 12px; + } + .launchpad-secondary { + flex-direction: column; + gap: 8px; + } + .launchpad-secondary .btn { + width: 100%; + height: 38px; + } + + /* ── Thumb-Zone iOS Bottom Toolbar ───────────────────────────────────── */ + .mobile-bottom-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: calc(56px + env(safe-area-inset-bottom)); + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)) 8px; + background: hsl(var(--card) / 0.9); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border-top: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + z-index: 60; + user-select: none; + } + .mob-stepper { + display: inline-flex; + align-items: center; + height: 36px; + padding: 2px; + border-radius: 9px; + background: hsl(var(--ton-1)); + box-shadow: inset 0 0 0 1px var(--kante-200); + gap: 1px; + flex-shrink: 0; + } + .mob-step-btn { + width: 28px; + height: 32px; + border-radius: 7px; + border: 0; + background: transparent; + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + .mob-step-btn:active { + background: hsl(var(--ton-3)); + } + .mob-step-btn:disabled { + opacity: 0.35; + pointer-events: none; + } + .mob-step-count { + min-width: 30px; + text-align: center; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 11px; + font-weight: 600; + color: hsl(var(--foreground)); + } + .mob-bar-btn { + height: 36px; + padding: 0 8px; + border-radius: 9px; + border: 0; + background: hsl(var(--ton-1)); + color: hsl(var(--foreground)); + box-shadow: inset 0 0 0 1px var(--kante-200); + font-family: var(--font-sans); + font-size: 11.5px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + } + .mob-bar-btn[aria-pressed="true"] { + background: hsl(var(--primary-ton-2)); + color: hsl(var(--primary-ink)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.35); + } + .mob-bar-btn.primary { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + box-shadow: none; + padding: 0 9px; + } + @media (max-width: 380px) { + #i18n-mob-filter, + .col-bar .col-role-pill { + display: none; + } + .header-brand { + padding-left: 10px; + gap: 6px; + } + .app-title { + font-size: 14.5px; + } + .header-tools { + padding-right: 8px; + gap: 4px; + } + .header-tools .btn, + #btn-paste-modal, + #btn-clear, + #btn-help { + width: 28px; + height: 28px; + min-width: 28px; + } + #btn-lang { + width: auto; + padding: 0 6px !important; + } + } + .mob-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: hsl(var(--ton-3)); + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 700; + } + + /* ── iOS Bottom Sheet for Changes List (.sidebar-panel) ──────────────── */ + .mobile-sheet-backdrop { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 0.45); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + z-index: 70; + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease-out; + display: block; + } + body.mobile-sheet-open .mobile-sheet-backdrop { + opacity: 1; + pointer-events: auto; + } + .sidebar-panel { + position: fixed !important; + top: auto !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: min(74dvh, 560px) !important; + border-left: 0 !important; + border-top: 1px solid hsl(var(--border)); + border-radius: 18px 18px 0 0; + background: hsl(var(--card)); + box-shadow: var(--elev-4); + z-index: 80 !important; + padding-bottom: env(safe-area-inset-bottom); + transform: translateY(0); + transition: transform 240ms cubic-bezier(0.32, 0.72, 0, 1) !important; + } + body.sidebar-collapsed .sidebar-panel { + transform: translateY(105%) !important; + pointer-events: none; + } + .mobile-sheet-header { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 14px 10px 14px; + border-bottom: 1px solid hsl(var(--border)); + flex-shrink: 0; + } + .sheet-grabber { + width: 36px; + height: 5px; + border-radius: 999px; + background: hsl(var(--border)); + margin-bottom: 8px; + } + .mobile-sheet-title-row { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + } + .mobile-sheet-title { + font-size: 15px; + font-weight: 700; + color: hsl(var(--foreground)); + } + .mobile-sheet-done { + height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 0; + background: hsl(var(--ton-2)); + color: hsl(var(--primary-ink)); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .nav-item { + padding: 10px 12px; + border-radius: 10px; + font-size: 13.5px; + } + .nav-item-head { + font-size: 11px; + margin-bottom: 4px; + } + + /* ── iOS Action Sheet for Export Menu (#export-menu) on Mobile ───────── */ + #export-menu { + position: fixed; + top: auto; + left: 12px; + right: 12px; + bottom: calc(66px + env(safe-area-inset-bottom)); + min-width: 0; + padding: 6px; + border-radius: 16px; + background: hsl(var(--card)); + box-shadow: 0 0 0 1px hsl(var(--border)), var(--elev-4); + z-index: 95; + } + .dropdown-item { + min-height: 44px; + padding: 10px 12px; + border-radius: 10px; + font-size: 14px; + font-weight: 500; + } + .dropdown-ext { + font-size: 11px; + } + + /* ── iOS Bottom Sheet Dialogs on Mobile ──────────────────────────────── */ + dialog.prime-dialog, + dialog.prime-dialog.dialog-md, + dialog.prime-dialog.dialog-sm { + margin: auto 0 0 0; + width: 100vw; + max-width: 100vw; + max-height: 90dvh; + border-radius: 18px 18px 0 0; + } + .dialog-header { + height: 48px; + padding: 0 16px; + font-size: 15px; + } + .dialog-body { + grid-template-columns: 1fr; + padding: 14px 16px; + max-height: calc(90dvh - 104px); + overflow-y: auto; + } + .dialog-body-single { + padding: 16px; + max-height: calc(90dvh - 104px); + overflow-y: auto; + font-size: 13.5px; + } + .dialog-textarea { + height: 150px; + font-size: 16px; + border-radius: 10px; + } + .dialog-footer { + height: calc(54px + env(safe-area-inset-bottom)); + padding: 0 16px env(safe-area-inset-bottom) 16px; + } + .dialog-footer .btn { + height: 36px; + padding: 0 14px; + border-radius: 9px; + font-size: 13px; + } +} + +@media print { + html, body { + height: auto !important; + overflow: visible !important; + background: #fff !important; + color: #000 !important; + } + .header-bar, .sidebar-panel, .col-sidebar-head, .center-gutter, #btn-upload-left, #btn-upload-right, #btn-swap-docs, .btn-clear-side, #export-dropdown-wrap, .col-edit-pill, .clause-actions, .ui-tooltip, .floating-action-bar, .mobile-bottom-bar, .mobile-sheet-backdrop { + display: none !important; + } + .col-bars { + grid-template-columns: 1fr 1fr !important; + height: 28px !important; + border-bottom: 1px solid #ccc !important; + } + .workspace { + height: auto !important; + display: grid !important; + grid-template-columns: 1fr 1fr !important; + } + body.view-inline .workspace { + grid-template-columns: 1fr !important; + } + .doc-pane, .doc-pane-unified { + height: auto !important; + overflow: visible !important; + padding: 8px 12px !important; + } + .clause.deferred-block { + content-visibility: visible !important; + } +} diff --git a/src/template.html b/src/template.html index 121b466..c36960f 100644 --- a/src/template.html +++ b/src/template.html @@ -61,2973 +61,7 @@ @@ -3437,3656 +471,7 @@ From 512cb382d01fe6282e7544735ef31b1a70ae4079 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 14:54:09 +0000 Subject: [PATCH 02/45] Harden PDF.js against eval-based font compilation and fix CSP drift - Pass isEvalSupported: false to PDF.js getDocument() (CVE-2024-4367 defense in depth on top of the CSP that already omits unsafe-eval). - Add manifest-src 'self' so default-src 'none' no longer blocks the web app manifest on docdiff.app. - README.md and SECURITY.md now quote the shipped CSP verbatim, and check.mjs fails the build if they drift again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tvts5ekgounJpaCtwNFnng --- DocDiff.html | 4 ++-- README.md | 2 +- SECURITY.md | 2 +- scripts/check.mjs | 10 ++++++++++ site/index.html | 4 ++-- src/js/09-import.js | 2 +- src/template.html | 2 +- 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/DocDiff.html b/DocDiff.html index c23fcf8..4986aba 100644 --- a/DocDiff.html +++ b/DocDiff.html @@ -21,7 +21,7 @@ - + @@ -6504,7 +6504,7 @@

${escapeHtml(title)}

} async function extractPdfText(arrayBuffer) { - const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise; + const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; const pageSections = []; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { diff --git a/README.md b/README.md index 40b382a..0da0e71 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Doc Diff is built so that any data protection officer or security auditor can ve 1. **Browser-Enforced Network Lock (`connect-src 'none'`):** The document embeds a strict Content Security Policy directly in ``: ```html - + ``` The browser engine itself blocks any outbound network request (`fetch`, `XMLHttpRequest`, `WebSocket`, `navigator.sendBeacon`, form submissions, or external image/script loads). 2. **Zero External Dependencies or CDNs:** diff --git a/SECURITY.md b/SECURITY.md index b059932..20ce34f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ Doc Diff is designed for comparing confidential legal contracts, tenders, and fi 1. **Enforced Content Security Policy (`connect-src 'none'`):** The HTML document embeds a strict Content Security Policy in ``: ```html - + ``` The browser engine itself blocks any outbound network connection (`fetch`, `XMLHttpRequest`, `WebSocket`, `navigator.sendBeacon`, form submissions, or external image/script requests). 2. **Zero External Dependencies at Runtime:** diff --git a/scripts/check.mjs b/scripts/check.mjs index 6c827af..1e1ba64 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -101,6 +101,16 @@ for (const lang of ['de', 'en']) { } assert.deepEqual(deKeys, enKeys, 'German and English i18n keys must match 1:1'); +// The CSP quoted in the docs must match the one that actually ships +const shippedCsp = siteHtml.match(/ - + @@ -6504,7 +6504,7 @@

${escapeHtml(title)}

} async function extractPdfText(arrayBuffer) { - const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise; + const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; const pageSections = []; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { diff --git a/src/js/09-import.js b/src/js/09-import.js index 60fff26..accc59d 100644 --- a/src/js/09-import.js +++ b/src/js/09-import.js @@ -1,5 +1,5 @@ async function extractPdfText(arrayBuffer) { - const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise; + const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; const pageSections = []; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { diff --git a/src/template.html b/src/template.html index c36960f..5943ba3 100644 --- a/src/template.html +++ b/src/template.html @@ -21,7 +21,7 @@ - + From bdd167d690c7fbb42f1263afc6f733f751a66c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 14:54:22 +0000 Subject: [PATCH 03/45] Add tag-triggered release workflow that attaches DocDiff.html Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tvts5ekgounJpaCtwNFnng --- .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0bc3df0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +# Pushing a tag like v1.5.0 builds the bundle, verifies it, and publishes a +# GitHub release with DocDiff.html attached. Release notes are read from +# docs/releases/.md (title on the first line as "# "). +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Build and verify standalone bundle + run: | + python3 scripts/build.py + node scripts/check.mjs + git diff --exit-code site/index.html DocDiff.html + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + notes="docs/releases/${TAG}.md" + test -f "$notes" || { echo "Missing $notes"; exit 1; } + title="$(head -n 1 "$notes" | sed 's/^# *//')" + tail -n +2 "$notes" > /tmp/notes.md + gh release create "$TAG" DocDiff.html --title "$title" --notes-file /tmp/notes.md --verify-tag From aed8ac7fe8180cc33b62f414fb4a5d3539be9220 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 22 Oct 2026 15:04:49 +0000 Subject: [PATCH 04/45] CI: rebuild before Pages deploy and tighten the storage API check pages.yml deployed the committed site/ without rebuilding, so a stale bundle could ship. It now builds, runs check.mjs, and fails if the committed bundle differs. check.mjs now matches bracket and bare access to storage APIs (e.g. window['localStorage']), not only the dotted form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tvts5ekgounJpaCtwNFnng --- .github/workflows/pages.yml | 10 ++++++++-- scripts/check.mjs | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d24ca8e..1171987 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,11 +18,17 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' - uses: actions/setup-node@v4 with: node-version: '22' - - name: Verify security and standalone invariants - run: node scripts/check.mjs + - name: Build and verify that the committed bundle is current + run: | + python3 scripts/build.py + node scripts/check.mjs + git diff --exit-code site/index.html DocDiff.html deploy: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' diff --git a/scripts/check.mjs b/scripts/check.mjs index 1e1ba64..29219a8 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -76,9 +76,11 @@ for (const requiredFn of [ assert.ok(appJsCode.includes(requiredFn), `Missing required function: ${requiredFn}`); } -for (const forbiddenStorage of ['localStorage.', 'sessionStorage.', 'indexedDB.', 'document.cookie']) { +// Match any mention (dot, bracket or bare access), not just `localStorage.` +for (const forbiddenStorage of ['localStorage', 'sessionStorage', 'indexedDB', 'document.cookie', 'caches.open', 'openDatabase']) { + const pattern = new RegExp(`\\b${forbiddenStorage.replace('.', '\\.')}\\b`); assert.ok( - !appScript.includes(forbiddenStorage), + !pattern.test(appScript), `App script must never use ${forbiddenStorage} (strict zero-persistence requirement)` ); } From 33561d05d5c66dd77412429be61d27006ac7b772 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 22 Oct 2026 15:10:39 +0000 Subject: [PATCH 05/45] Rebuild exports: real .docx redline, accessible HTML report, safe Markdown, print view - Word: replace the HTML-as-.doc export with a genuine .docx generated in the browser (minimal STORE ZIP writer with CRC-32 plus hand-written WordprocessingML). Every difference is a real tracked change (w:ins/w:del, author "Doc Diff", w:date), inserted/deleted paragraphs also track the paragraph mark, so Accept all / Reject all yield exactly the new / old text. Heading 1-3, clause and bullet styles with hanging indents, title and metadata block, A4 page setup, footer with page X of Y, no compatibility mode. - HTML report: self-contained read-only report with strict CSP, lang, summary counts, linked change list with clause references, legend, full redline, del/ins with visually hidden "removed:/added:" text, dark-mode and print CSS. States scope ("changes only" or full) and the header/footer setting. - Markdown: escape document text, keep whitespace outside ~~ / ** delimiters, fall back to <del>/<ins> where CommonMark flanking rules would fail, add a summary table with clause references. - Print/PDF: dedicated row-aligned print view for pages, split and inline modes with file names, date, counts, repeated column headers, page numbers in the page margin, break-inside: avoid, greyscale-safe marks. - Copy/.txt: shared final-text builder, header/footer lines always excluded, internal intake markers filtered defensively. - File names: both document names (e.g. Vertrag_v2_vs_v1_Redline.docx), sanitized for Windows/macOS/Linux, localized suffixes. - Export menu: real disabled/aria-disabled state until a comparison exists, no more silent sample substitution, menu keyboard pattern (arrows, Home/End, Escape returns focus), .docx label, all strings in de and en. - Add scripts/test-export.mjs (node:vm, no dependencies) and run it from scripts/check.mjs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tvts5ekgounJpaCtwNFnng --- DocDiff.html | 1545 +++++++++++++++++++++++++++++++++++--- README.md | 11 +- scripts/check.mjs | 3 + scripts/test-export.mjs | 241 ++++++ site/index.html | 1545 +++++++++++++++++++++++++++++++++++--- src/js/01-i18n.js | 82 +- src/js/08-export-docx.js | 371 +++++++++ src/js/08-export.js | 916 +++++++++++++++++++--- src/styles.css | 175 +++++ src/template.html | 4 +- 10 files changed, 4541 insertions(+), 352 deletions(-) create mode 100644 scripts/test-export.mjs create mode 100644 src/js/08-export-docx.js diff --git a/DocDiff.html b/DocDiff.html index 4986aba..0ad2711 100644 --- a/DocDiff.html +++ b/DocDiff.html @@ -3092,6 +3092,181 @@ } } +/* ── Print view (built by preparePrintView() on beforeprint / "PDF / Print") ── + A dedicated, row-aligned rendering for paper: no UI chrome, a header with both file names + and the date, page numbers in the page margin, and redline marks that survive greyscale + printing (strike-through for removed text, underline for added text). */ +#print-view { + display: none; +} + +@media print { + body.has-print-view > :not(#print-view) { + display: none !important; + } + body.has-print-view #print-view { + display: block !important; + font-family: "Geist", -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + font-size: 9.5pt; + line-height: 1.45; + color: #000; + background: #fff; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + #print-view .pv-head { + border-bottom: 1.5pt solid #000; + padding-bottom: 6pt; + margin-bottom: 10pt; + break-after: avoid; + } + #print-view .pv-title { + font-size: 14pt; + font-weight: 700; + margin-bottom: 4pt; + } + #print-view .pv-meta { + display: flex; + flex-wrap: wrap; + gap: 2pt 14pt; + font-size: 8.5pt; + color: #333; + } + #print-view .pv-legend { + margin-top: 4pt; + font-size: 8.5pt; + color: #333; + } + #print-view del.tok-del, + #print-view ins.tok-ins { + padding: 0 1pt; + border-radius: 1pt; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + } + #print-view del.tok-del { + color: #8a0021; + background: #ffe3e8; + text-decoration: line-through; + text-decoration-thickness: 1pt; + } + #print-view ins.tok-ins { + color: #0d5a26; + background: #dcf7e4; + text-decoration: underline; + text-decoration-thickness: 1pt; + text-underline-offset: 1.5pt; + font-weight: 600; + } + #print-view .pv-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + } + #print-view .pv-page-head { + break-after: avoid; + page-break-after: avoid; + } + #print-view .pv-table thead { + display: table-header-group; + } + #print-view .pv-table thead th { + text-align: left; + font-size: 8pt; + font-weight: 600; + color: #333; + padding: 3pt 6pt; + border-bottom: 1pt solid #888; + overflow-wrap: anywhere; + } + #print-view .pv-page-head th { + text-align: left; + font-size: 8pt; + font-weight: 600; + color: #555; + padding: 6pt 6pt 2pt; + } + #print-view .pv-row, + #print-view .pv-table tr { + break-inside: avoid; + page-break-inside: avoid; + } + #print-view .pv-cell { + vertical-align: top; + padding: 3pt 6pt; + border-bottom: 0.5pt solid #ddd; + overflow-wrap: anywhere; + hyphens: auto; + } + #print-view .pv-cell + .pv-cell { + border-left: 0.5pt solid #bbb; + } + #print-view .pv-empty { + background: repeating-linear-gradient(135deg, transparent 0 4pt, #eee 4pt 5pt); + } + #print-view .pv-cell.kind-h1, + #print-view .pv-row.kind-h1 { + font-size: 12pt; + font-weight: 700; + } + #print-view .pv-cell.kind-h2, + #print-view .pv-row.kind-h2 { + font-size: 10.5pt; + font-weight: 700; + break-after: avoid; + } + #print-view .pv-cell.kind-h3, + #print-view .pv-row.kind-h3 { + font-weight: 600; + break-after: avoid; + } + #print-view .pv-cell.kind-hf, + #print-view .pv-row.kind-hf { + font-size: 7.5pt; + font-style: italic; + color: #555; + } + #print-view .clause-marker { + display: inline-block; + min-width: 2.2em; + } + #print-view .clause-body { + display: inline; + } + #print-view .pv-tag { + display: block; + font-size: 7pt; + font-weight: 600; + font-style: normal; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #444; + } + #print-view .pv-modified > .pv-cell, + #print-view .pv-inline .pv-modified { + border-left: 2pt solid #1d4ed8; + } + #print-view .pv-deleted > .pv-cell:first-child, + #print-view .pv-inline .pv-deleted { + border-left: 2pt dashed #8a0021; + } + #print-view .pv-inserted > .pv-cell:last-child, + #print-view .pv-inline .pv-inserted { + border-left: 2pt double #0d5a26; + } + #print-view .pv-inline .pv-row { + padding: 2pt 0 2pt 8pt; + margin-bottom: 5pt; + } + #print-view .pv-inline .pv-equal { + border-left: 2pt solid transparent; + } + #print-view .pv-empty-note { + font-style: italic; + color: #444; + } +} + </style> </head> <body> @@ -3271,9 +3446,9 @@ <button type="button" class="dropdown-item" data-export="word" role="menuitem"> <span class="dropdown-item-left"> <svg class="icon" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> - <span id="i18n-exp-word">Word-Redline (mit Änderungen)</span> + <span id="i18n-exp-word">Word-Redline (Änderungen nachverfolgen)</span> </span> - <span class="dropdown-ext">.doc</span> + <span class="dropdown-ext">.docx</span> </button> <button type="button" class="dropdown-item" data-export="print" role="menuitem"> <span class="dropdown-item-left"> @@ -3536,9 +3711,48 @@ expTxt: 'Neuen Stand speichern', copiedLabel: 'Kopiert', expPdf: 'PDF / Druckansicht', - expWord: 'Word-Redline (mit Änderungen)', + expWord: 'Word-Redline (Änderungen nachverfolgen)', expHtml: 'HTML-Vergleichsbericht', expMd: 'Markdown-Bericht', + exportFallbackLeft: 'Originaldokument', + exportFallbackRight: 'Neue Fassung', + exportTitleFmt: 'Vergleich: {left} → {right}', + exportVs: 'vs.', + exportCountsFmt: '{total} Änderungen ({mod} geändert, {ins} neu, {del} entfernt)', + exportLabelOriginal: 'Original (vorher)', + exportLabelRevised: 'Neue Fassung (nachher)', + exportLabelCreated: 'Erstellt am', + exportLabelChanges: 'Änderungen', + exportLabelScope: 'Umfang', + exportLabelHf: 'Kopf-/Fußzeilen', + exportLabelDetail: 'Angabe', + exportScopeFull: 'Vollständiges Dokument', + exportScopeChanges: 'Nur Änderungen (Filter „Nur Änderungen“ aktiv, unveränderte Absätze ausgelassen)', + exportHfIgnored: 'ausgeblendet (nicht verglichen)', + exportHfIgnoredCount: 'ausgeblendet ({n} Zeilen nicht verglichen)', + exportHfIncluded: 'mitverglichen', + exportSummaryHeading: 'Zusammenfassung', + exportCountTotal: 'Änderungen gesamt', + exportChangesHeading: 'Änderungsübersicht', + exportChangeListCaption: 'Alle Änderungen mit Fundstelle. Ein Klick auf die Fundstelle springt zur Stelle im Redline.', + exportColRef: 'Fundstelle', + exportColType: 'Art', + exportColChange: 'Änderung', + exportRedlineHeading: 'Redline (vorher → nachher)', + exportLegendLabel: 'Legende', + exportLegendIns: 'eingefügt', + exportLegendDel: 'gelöscht', + exportNoChanges: 'Keine Unterschiede gefunden: Beide Fassungen sind inhaltlich identisch.', + exportFooterNote: 'Erstellt mit Doc Diff. Alle Inhalte wurden lokal im Browser verarbeitet, es wurden keine Daten hochgeladen.', + a11yRemovedStart: '[gelöscht:', + a11yRemovedEnd: ']', + a11yAddedStart: '[eingefügt:', + a11yAddedEnd: ']', + docxSubject: 'Redline mit nachverfolgten Änderungen', + docxNote: 'Alle Abweichungen sind als nachverfolgte Änderungen (Autor „Doc Diff“) eingetragen. In Word unter „Überprüfen“ einzeln oder alle auf einmal annehmen bzw. ablehnen.', + exportFileFinal: 'Endfassung', + exportFileRedline: 'Redline', + exportFileReport: 'Bericht', editBtn: 'Bearbeiten', editInsertBtn: 'Einfügen', editTitle: 'Diesen Absatz im neuen Stand direkt bearbeiten (oder Doppelklick)', @@ -3701,9 +3915,48 @@ expTxt: 'Save new version (.txt)', copiedLabel: 'Copied', expPdf: 'PDF / Print view', - expWord: 'Word Redline (with changes)', + expWord: 'Word redline (tracked changes)', expHtml: 'HTML Diff Report', expMd: 'Markdown Report', + exportFallbackLeft: 'Original document', + exportFallbackRight: 'Revised document', + exportTitleFmt: 'Comparison: {left} → {right}', + exportVs: 'vs.', + exportCountsFmt: '{total} changes ({mod} modified, {ins} added, {del} removed)', + exportLabelOriginal: 'Original (before)', + exportLabelRevised: 'Revised (after)', + exportLabelCreated: 'Created', + exportLabelChanges: 'Changes', + exportLabelScope: 'Scope', + exportLabelHf: 'Headers/footers', + exportLabelDetail: 'Detail', + exportScopeFull: 'Full document', + exportScopeChanges: 'Changes only (“Changes only” filter active, unchanged paragraphs omitted)', + exportHfIgnored: 'ignored (not compared)', + exportHfIgnoredCount: 'ignored ({n} lines not compared)', + exportHfIncluded: 'included in the comparison', + exportSummaryHeading: 'Summary', + exportCountTotal: 'Total changes', + exportChangesHeading: 'List of changes', + exportChangeListCaption: 'All changes with their clause reference. Select a reference to jump to it in the redline.', + exportColRef: 'Reference', + exportColType: 'Type', + exportColChange: 'Change', + exportRedlineHeading: 'Redline (before → after)', + exportLegendLabel: 'Legend', + exportLegendIns: 'added', + exportLegendDel: 'removed', + exportNoChanges: 'No differences found: both versions have identical content.', + exportFooterNote: 'Generated with Doc Diff. All content was processed locally in the browser; no data was uploaded.', + a11yRemovedStart: '[removed:', + a11yRemovedEnd: ']', + a11yAddedStart: '[added:', + a11yAddedEnd: ']', + docxSubject: 'Redline with tracked changes', + docxNote: 'All differences are recorded as tracked changes (author “Doc Diff”). In Word, use Review › Accept / Reject to process them one by one or all at once.', + exportFileFinal: 'Final', + exportFileRedline: 'Redline', + exportFileReport: 'Report', editBtn: 'Edit', editInsertBtn: 'Insert', editTitle: 'Edit this paragraph directly in the new version (or double-click)', @@ -6321,112 +6574,1079 @@ <h1 class="launchpad-title">${escapeHtml(t('lpTitle'))}</h1> }, 60); }); -/* ── Client-Side Multi-Format Export Engine (PDF, Word .doc, HTML, Markdown) */ +/* ── Word redline export: a real .docx (OOXML) with tracked changes, built in the browser ── + * Everything here is dependency-free: a minimal ZIP writer (STORE, CRC-32) plus hand-written + * WordprocessingML. Changes are emitted as genuine revisions (<w:ins>/<w:del>), so Word, + * LibreOffice and Google Docs offer "Accept / Reject change" on every edit. + */ + +const DOCX_AUTHOR = 'Doc Diff'; +const DOCX_TAB = '\u0000TAB\u0000'; + +let crc32Table = null; +function crc32(bytes) { + if (!crc32Table) { + crc32Table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crc32Table[n] = c >>> 0; + } + } + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i++) crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +/** + * Minimal ZIP writer (method 0 = STORE). `files` is [{ name, data: Uint8Array | string }]. + * Returns a Uint8Array containing a valid archive with central directory. + */ +function buildZipArchive(files, date = new Date()) { + const enc = new TextEncoder(); + const dosTime = ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | ((date.getSeconds() / 2) & 31); + const dosDate = (((date.getFullYear() - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31); + + const entries = files.map(f => { + const nameBytes = enc.encode(f.name); + const data = typeof f.data === 'string' ? enc.encode(f.data) : f.data; + return { nameBytes, data, crc: crc32(data) }; + }); + + let localSize = 0; + let centralSize = 0; + for (const e of entries) { + localSize += 30 + e.nameBytes.length + e.data.length; + centralSize += 46 + e.nameBytes.length; + } + const out = new Uint8Array(localSize + centralSize + 22); + const view = new DataView(out.buffer); + let pos = 0; + + const writeHeaderCommon = e => { + view.setUint16(pos, 0x0800, true); // flags: bit 11 = UTF-8 file names + view.setUint16(pos + 2, 0, true); // method: STORE + view.setUint16(pos + 4, dosTime, true); + view.setUint16(pos + 6, dosDate, true); + view.setUint32(pos + 8, e.crc, true); + view.setUint32(pos + 12, e.data.length, true); // compressed size + view.setUint32(pos + 16, e.data.length, true); // uncompressed size + view.setUint16(pos + 20, e.nameBytes.length, true); + view.setUint16(pos + 22, 0, true); // extra field length + pos += 24; + }; + + for (const e of entries) { + e.offset = pos; + view.setUint32(pos, 0x04034b50, true); + view.setUint16(pos + 4, 20, true); // version needed + pos += 6; + writeHeaderCommon(e); + out.set(e.nameBytes, pos); + pos += e.nameBytes.length; + out.set(e.data, pos); + pos += e.data.length; + } + + const centralStart = pos; + for (const e of entries) { + view.setUint32(pos, 0x02014b50, true); + view.setUint16(pos + 4, 20, true); // version made by + view.setUint16(pos + 6, 20, true); // version needed + pos += 8; + writeHeaderCommon(e); + view.setUint16(pos, 0, true); // comment length + view.setUint16(pos + 2, 0, true); // disk number start + view.setUint16(pos + 4, 0, true); // internal attributes + view.setUint32(pos + 6, 0, true); // external attributes + view.setUint32(pos + 10, e.offset, true); + pos += 14; + out.set(e.nameBytes, pos); + pos += e.nameBytes.length; + } + + view.setUint32(pos, 0x06054b50, true); + view.setUint16(pos + 4, 0, true); + view.setUint16(pos + 6, 0, true); + view.setUint16(pos + 8, entries.length, true); + view.setUint16(pos + 10, entries.length, true); + view.setUint32(pos + 12, pos - centralStart, true); + view.setUint32(pos + 16, centralStart, true); + view.setUint16(pos + 20, 0, true); + return out; +} + +/** Escapes text for XML and drops characters that are illegal in XML 1.0. */ +function escapeXml(str) { + return String(str) + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, '') + .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} + +/** Word dates must be ISO 8601 without milliseconds. */ +function toWordDate(date) { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** Run content (w:t / w:delText with tabs and line breaks) for one text segment. */ +function docxRunContent(text, deleted) { + const tag = deleted ? 'w:delText' : 'w:t'; + const parts = []; + const pieces = String(text).split(/(\u0000TAB\u0000|\t|\n)/); + for (const piece of pieces) { + if (!piece) continue; + if (piece === DOCX_TAB || piece === '\t') parts.push('<w:tab/>'); + else if (piece === '\n') parts.push('<w:br/>'); + else parts.push(`<${tag} xml:space="preserve">${escapeXml(piece)}</${tag}>`); + } + return parts.join(''); +} + +function docxRun(text, deleted = false, rPr = '') { + const content = docxRunContent(text, deleted); + if (!content) return ''; + return `<w:r>${rPr ? `<w:rPr>${rPr}</w:rPr>` : ''}${content}</w:r>`; +} + +/** Maps a parsed block to its paragraph style. */ +function docxStyleForBlock(block) { + if (!block) return 'Normal'; + if (block.isHeaderFooter || block.kind === 'hf') return 'DocDiffHeaderFooter'; + if (block.kind === 'h1') return 'Heading1'; + if (block.kind === 'h2') return 'Heading2'; + if (block.kind === 'h3') return 'Heading3'; + if (block.kind === 'bullet') return 'DocDiffBullet'; + if (block.kind === 'clause') return 'DocDiffClause'; + return 'Normal'; +} + +/** + * For list-like paragraphs (bullets, numbered clauses) replace the space after the leading marker + * with a tab so that the hanging indent of the paragraph style lines up the body text. + */ +function docxApplyMarkerTab(tokens, block) { + if (!tokens.length || !block || (block.kind !== 'bullet' && block.kind !== 'clause')) return tokens; + const first = tokens[0]; + const m = first.value.match(/^([•‣▪◦–*-]|\(\d+\)|\([a-z]\)|\([ivx]+\)|\d+\)|[a-z]\))[ \t]+/i); + if (!m) return tokens; + const rest = first.value.slice(m[0].length); + const replaced = [{ ...first, value: m[1] + DOCX_TAB }]; + if (rest) replaced.push({ ...first, value: rest }); + return replaced.concat(tokens.slice(1)); +} + +/** + * Builds the WordprocessingML body for the comparison. Pure function of `rows` and `meta` + * so it can be unit-tested without a browser. + */ +function buildDocxDocumentXml(rows, meta) { + const date = toWordDate(meta.date); + let revId = 1; + const revAttrs = () => `w:id="${revId++}" w:author="${escapeXml(DOCX_AUTHOR)}" w:date="${date}"`; + const paragraphs = []; + + const metaPara = (label, value) => + `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/></w:pPr>${docxRun(`${label}: `, false, '<w:b/>')}${docxRun(value)}</w:p>`; + + paragraphs.push(`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr>${docxRun(meta.title)}</w:p>`); + for (const [label, value] of meta.lines) paragraphs.push(metaPara(label, value)); + paragraphs.push( + `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="6" w:color="A6A6A6"/></w:pBdr><w:spacing w:after="360"/></w:pPr>${docxRun(meta.note, false, '<w:i/>')}</w:p>` + ); + + for (const row of rows) { + const block = row.rightBlock || row.leftBlock; + const style = docxStyleForBlock(block); + const tokens = docxApplyMarkerTab(getExportDiffTokens(row), block); + let markRev = ''; + if (row.type === 'inserted') markRev = `<w:rPr><w:ins ${revAttrs()}/></w:rPr>`; + if (row.type === 'deleted') markRev = `<w:rPr><w:del ${revAttrs()}/></w:rPr>`; + const pPr = `<w:pPr><w:pStyle w:val="${style}"/>${markRev}</w:pPr>`; + + const runs = tokens + .map(tok => { + if (tok.removed) { + const run = docxRun(tok.value, true); + return run ? `<w:del ${revAttrs()}>${run}</w:del>` : ''; + } + if (tok.added) { + const run = docxRun(tok.value, false); + return run ? `<w:ins ${revAttrs()}>${run}</w:ins>` : ''; + } + return docxRun(tok.value, false); + }) + .join(''); + paragraphs.push(`<w:p>${pPr}${runs}</w:p>`); + } + + const sectPr = + '<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter1"/>' + + '<w:pgSz w:w="11906" w:h="16838"/>' + + '<w:pgMar w:top="1417" w:right="1417" w:bottom="1134" w:left="1417" w:header="708" w:footer="567" w:gutter="0"/>' + + '</w:sectPr>'; + + return ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + + `<w:body>${paragraphs.join('')}${sectPr}</w:body></w:document>` + ); +} + +function buildDocxFooterXml(meta) { + return ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + + '<w:p><w:pPr><w:pStyle w:val="Footer"/><w:tabs><w:tab w:val="right" w:pos="9072"/></w:tabs></w:pPr>' + + docxRun(meta.footer) + + '<w:r><w:tab/></w:r>' + + docxRun(`${meta.pageLabel} `) + + '<w:fldSimple w:instr=" PAGE "><w:r><w:t>1</w:t></w:r></w:fldSimple>' + + docxRun(` ${meta.pageOfLabel} `) + + '<w:fldSimple w:instr=" NUMPAGES "><w:r><w:t>1</w:t></w:r></w:fldSimple>' + + '</w:p></w:ftr>' + ); +} + +function buildDocxStylesXml(langTag) { + const font = '<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Calibri" w:cs="Calibri"/>'; + const heading = (id, name, level, size, before, after) => + `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:uiPriority w:val="9"/><w:qFormat/>` + + `<w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="${before}" w:after="${after}"/><w:outlineLvl w:val="${level}"/></w:pPr>` + + `<w:rPr><w:b/><w:bCs/><w:color w:val="1F2937"/><w:sz w:val="${size}"/><w:szCs w:val="${size}"/></w:rPr></w:style>`; + return ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + + `<w:docDefaults><w:rPrDefault><w:rPr>${font}<w:sz w:val="22"/><w:szCs w:val="22"/><w:lang w:val="${langTag}"/></w:rPr></w:rPrDefault>` + + '<w:pPrDefault><w:pPr><w:spacing w:after="140" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>' + + '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>' + + '<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:uiPriority w:val="10"/><w:qFormat/>' + + '<w:pPr><w:spacing w:after="160"/></w:pPr><w:rPr><w:b/><w:color w:val="111827"/><w:sz w:val="34"/><w:szCs w:val="34"/></w:rPr></w:style>' + + heading('Heading1', 'heading 1', 0, 30, 360, 160) + + heading('Heading2', 'heading 2', 1, 25, 300, 120) + + heading('Heading3', 'heading 3', 2, 23, 240, 100) + + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="DocDiffClause"><w:name w:val="Doc Diff Clause"/><w:basedOn w:val="Normal"/><w:qFormat/>' + + '<w:pPr><w:tabs><w:tab w:val="left" w:pos="567"/></w:tabs><w:ind w:left="567" w:hanging="567"/></w:pPr></w:style>' + + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="DocDiffBullet"><w:name w:val="Doc Diff Bullet"/><w:basedOn w:val="Normal"/><w:qFormat/>' + + '<w:pPr><w:tabs><w:tab w:val="left" w:pos="720"/></w:tabs><w:spacing w:after="80"/><w:ind w:left="720" w:hanging="360"/></w:pPr></w:style>' + + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="DocDiffMeta"><w:name w:val="Doc Diff Metadata"/><w:basedOn w:val="Normal"/>' + + '<w:pPr><w:spacing w:after="40"/></w:pPr><w:rPr><w:color w:val="4B5563"/><w:sz w:val="19"/><w:szCs w:val="19"/></w:rPr></w:style>' + + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="DocDiffHeaderFooter"><w:name w:val="Doc Diff Header/Footer Line"/><w:basedOn w:val="Normal"/>' + + '<w:rPr><w:i/><w:color w:val="6B7280"/><w:sz w:val="17"/><w:szCs w:val="17"/></w:rPr></w:style>' + + '<w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/>' + + '<w:pPr><w:spacing w:after="0"/></w:pPr><w:rPr><w:color w:val="6B7280"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr></w:style>' + + '</w:styles>' + ); +} + +/** Returns the list of files that make up the .docx package. */ +function buildDocxPackageFiles(rows, meta) { + const langTag = meta.lang === 'de' ? 'de-DE' : 'en-US'; + const created = toWordDate(meta.date); + return [ + { + name: '[Content_Types].xml', + data: + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' + + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' + + '<Default Extension="xml" ContentType="application/xml"/>' + + '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' + + '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>' + + '<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>' + + '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' + + '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' + + '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' + + '</Types>', + }, + { + name: '_rels/.rels', + data: + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + + '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' + + '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' + + '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' + + '</Relationships>', + }, + { + name: 'docProps/core.xml', + data: + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' + + 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' + + 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' + + `<dc:title>${escapeXml(meta.title)}</dc:title>` + + `<dc:subject>${escapeXml(meta.subject)}</dc:subject>` + + `<dc:creator>${DOCX_AUTHOR}</dc:creator>` + + `<cp:lastModifiedBy>${DOCX_AUTHOR}</cp:lastModifiedBy>` + + `<dc:language>${langTag}</dc:language>` + + `<dcterms:created xsi:type="dcterms:W3CDTF">${created}</dcterms:created>` + + `<dcterms:modified xsi:type="dcterms:W3CDTF">${created}</dcterms:modified>` + + '</cp:coreProperties>', + }, + { + name: 'docProps/app.xml', + data: + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">' + + '<Application>Doc Diff</Application></Properties>', + }, + { + name: 'word/_rels/document.xml.rels', + data: + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + + '<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' + + '<Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>' + + '<Relationship Id="rIdFooter1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>' + + '</Relationships>', + }, + { name: 'word/document.xml', data: buildDocxDocumentXml(rows, meta) }, + { name: 'word/styles.xml', data: buildDocxStylesXml(langTag) }, + { + name: 'word/settings.xml', + data: + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + + '<w:defaultTabStop w:val="708"/><w:characterSpacingControl w:val="doNotCompress"/>' + + '<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat>' + + '</w:settings>', + }, + { name: 'word/footer1.xml', data: buildDocxFooterXml(meta) }, + ]; +} + +/** Builds the complete .docx file (Uint8Array) for the current comparison state. */ +function buildDocxRedline(date = new Date()) { + const counts = getExportCounts(); + const meta = { + lang: state.lang, + date, + title: getExportTitle(), + subject: t('docxSubject'), + lines: [ + [t('exportLabelOriginal'), getDisplayDocName('left')], + [t('exportLabelRevised'), getDisplayDocName('right')], + [t('exportLabelCreated'), formatExportDate(date)], + [t('exportLabelChanges'), formatExportCountsLine(counts)], + [t('exportLabelHf'), state.ignoreHeadersFooters ? t('exportHfIgnored') : t('exportHfIncluded')], + ], + note: t('docxNote'), + footer: `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`, + pageLabel: t('pageLabel'), + pageOfLabel: t('pageOf'), + }; + // The redline always contains the full document so that "Accept all" yields the new version. + return buildZipArchive(buildDocxPackageFiles(state.alignedRows, meta), date); +} + +/* ── Client-Side Multi-Format Export Engine (copy, .txt, .docx, print/PDF, HTML, Markdown) ── */ + function downloadBlob(content, mimeType, filename) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; + a.rel = 'noopener'; document.body.appendChild(a); a.click(); document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 1000); + setTimeout(() => URL.revokeObjectURL(url), 4000); } -function buildExportHtmlDocument(forWord = false) { - const rows = state.onlyChanges ? state.changeRows : state.alignedRows; - const title = `Doc Diff — ${state.leftName || 'Original'} vs. ${state.rightName || 'Revised'}`; - const bodyRows = rows - .map(row => { - const primaryBlock = row.rightBlock || row.leftBlock; - const isHeading = primaryBlock && primaryBlock.isHeading; - const tag = isHeading ? 'h3' : 'p'; - const borderColor = - row.type === 'modified' - ? '#0058b8' - : row.type === 'inserted' - ? '#2a9d4e' - : row.type === 'deleted' - ? '#b30027' - : 'transparent'; - const bgColor = - row.type === 'inserted' - ? '#f2fbf5' - : row.type === 'deleted' - ? '#fff5f7' - : 'transparent'; - return `<${tag} style="margin:0 0 8px 0;padding:4px 10px;border-left:3px solid ${borderColor};background:${bgColor};font-size:${ - isHeading ? '13px' : '11.5px' - };line-height:1.5;">${renderUnifiedBlockHtml(row, '')}</${tag}>`; +/** Simple `{name}` placeholder formatting for translated strings. */ +function tf(key, vars) { + return t(key).replace(/\{(\w+)\}/g, (m, name) => (vars && name in vars ? String(vars[name]) : m)); +} + +/* ── File names ───────────────────────────────────────────────────────────── */ + +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i; + +/** + * Turns an arbitrary document name into a safe file-name fragment: strips the extension, + * path separators, characters invalid on Windows/macOS/Linux and control characters, + * avoids reserved device names, and caps the length. Returns `fallback` if nothing remains. + */ +function sanitizeFileNamePart(name, fallback = '', maxLen = 60) { + let s = String(name || '').normalize('NFC'); + s = s.split(/[\\/]/).pop(); + s = s.replace(/\.[A-Za-z0-9]{1,5}$/, ''); + s = s + .replace(/[\u0000-\u001f\u007f-\u009f<>:"|?*\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]+/g, '_') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^[._\-]+|[._\-]+$/g, ''); + const chars = Array.from(s); + if (chars.length > maxLen) s = chars.slice(0, maxLen).join('').replace(/[._\-]+$/, ''); + if (WINDOWS_RESERVED_NAME.test(s.split('.')[0])) s = `_${s}`; + return s || fallback; +} + +/** + * Short, readable base name that contains both documents, newest first, e.g. + * "Vertrag_v1.pdf" + "Vertrag_v2.pdf" -> "Vertrag_v2_vs_v1". + */ +function buildExportBaseName(leftName, rightName) { + const left = sanitizeFileNamePart(leftName); + const right = sanitizeFileNamePart(rightName); + if (!left && !right) return 'DocDiff'; + if (!left || !right || left.toLowerCase() === right.toLowerCase()) return right || left; + + let common = 0; + while (common < left.length && common < right.length && left[common] === right[common]) common++; + const boundary = Math.max(left.lastIndexOf('_', common - 1), left.lastIndexOf('-', common - 1)); + const leftShort = boundary >= 2 ? left.slice(boundary + 1) : left; + return `${right}_vs_${leftShort || left}`; +} + +function buildExportFileName(suffixKey, ext) { + const base = buildExportBaseName(state.leftName, state.rightName); + const suffix = sanitizeFileNamePart(t(suffixKey), 'Export'); + const name = `${base}_${suffix}`; + const chars = Array.from(name); + const capped = chars.length > 140 ? chars.slice(0, 140).join('').replace(/[._\-]+$/, '') : name; + return `${capped}.${ext}`; +} + +/* ── Shared export model ──────────────────────────────────────────────────── */ + +function getDisplayDocName(side) { + const name = side === 'left' ? state.leftName : state.rightName; + return name || t(side === 'left' ? 'exportFallbackLeft' : 'exportFallbackRight'); +} + +function getExportTitle() { + return tf('exportTitleFmt', { left: getDisplayDocName('left'), right: getDisplayDocName('right') }); +} + +function getExportCounts(rows = state.changeRows) { + const counts = { modified: 0, inserted: 0, deleted: 0, total: 0 }; + for (const row of rows) { + if (row.type in counts) { + counts[row.type]++; + counts.total++; + } + } + return counts; +} + +function formatExportCountsLine(c) { + return tf('exportCountsFmt', { total: c.total, mod: c.modified, ins: c.inserted, del: c.deleted }); +} + +function formatExportDate(date) { + try { + return date.toLocaleString(state.lang === 'de' ? 'de-DE' : 'en-GB', { + dateStyle: 'long', + timeStyle: 'short', + }); + } catch (e) { + return date.toISOString().slice(0, 16).replace('T', ' '); + } +} + +function getRowRefCode(row) { + const b = row.rightBlock || row.leftBlock; + return (b && b.refCode) || ''; +} + +function getRowTypeLabel(row) { + return row.type === 'modified' + ? t('typeMod') + : row.type === 'inserted' + ? t('typeIns') + : row.type === 'deleted' + ? t('typeDel') + : ''; +} + +/** Rows that go into a report body: honours the "changes only" filter. */ +function getExportBodyRows() { + return state.onlyChanges ? state.changeRows : state.alignedRows; +} + +function getExportScopeText() { + return state.onlyChanges ? t('exportScopeChanges') : t('exportScopeFull'); +} + +function getExportHfText() { + if (!state.ignoreHeadersFooters) return t('exportHfIncluded'); + return state.filteredHfCount > 0 + ? tf('exportHfIgnoredCount', { n: state.filteredHfCount }) + : t('exportHfIgnored'); +} + +/** + * Internal intake markers (page separators and the header/footer tag) must never leak into an + * export, even if a future intake path forgets to strip them. + */ +const INTERNAL_PAGE_MARKER = /^\s*===\s*\[(?:SEITE|PAGE)\s*\d+\]\s*===\s*$/i; +function isInternalMarkerLine(text) { + return INTERNAL_PAGE_MARKER.test(text || ''); +} +function cleanExportText(text) { + return String(text || '') + .replace(/===\s*\[(?:SEITE|PAGE)\s*\d+\]\s*===/gi, '') + .replace(/\[\[HF\]\]\s*/g, ''); +} + +/** + * Normalised diff tokens for one row: `{ value, removed?, added? }`. Change runs (including the + * single spaces that bridge them) are grouped into one deletion followed by one insertion. The + * tokens are guaranteed to reproduce the old text (all non-added) and the new text (all + * non-removed) exactly; if the word diff ever disagrees, the row falls back to a whole-paragraph + * replacement so that "Accept all" / "Reject all" in Word are always correct. + */ +function getExportDiffTokens(row) { + const leftText = cleanExportText(row.leftBlock ? row.leftBlock.text : ''); + const rightText = cleanExportText(row.rightBlock ? row.rightBlock.text : ''); + if (row.type === 'equal') return [{ value: rightText }]; + if (row.type === 'inserted') return [{ value: rightText, added: true }]; + if (row.type === 'deleted') return [{ value: leftText, removed: true }]; + + const diffs = row.wordDiffs || []; + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const isBridge = idx => + idx > 0 && + idx + 1 < diffs.length && + !isChange(diffs[idx]) && + /^\s+$/.test(diffs[idx].value) && + isChange(diffs[idx - 1]) && + isChange(diffs[idx + 1]); + + const out = []; + let i = 0; + while (i < diffs.length) { + if (!isChange(diffs[i])) { + const last = out[out.length - 1]; + const value = cleanExportText(diffs[i].value); + if (last && !last.removed && !last.added) last.value += value; + else out.push({ value }); + i++; + continue; + } + let del = ''; + let ins = ''; + while (i < diffs.length && (isChange(diffs[i]) || isBridge(i))) { + const value = cleanExportText(diffs[i].value); + if (!diffs[i].added) del += value; + if (!diffs[i].removed) ins += value; + i++; + } + if (del) out.push({ value: del, removed: true }); + if (ins) out.push({ value: ins, added: true }); + } + + const oldJoined = out.filter(tok => !tok.added).map(tok => tok.value).join(''); + const newJoined = out.filter(tok => !tok.removed).map(tok => tok.value).join(''); + if (oldJoined !== leftText || newJoined !== rightText) { + return [ + { value: leftText, removed: true }, + { value: rightText, added: true }, + ]; + } + return out; +} + +/** Short "old → new" summary of a change, used by the change lists. */ +function getRowChangeSummary(row, maxLen = 70) { + if (row.type === 'inserted') return { pairs: [{ del: '', ins: truncateClean(row.rightBlock.text, maxLen * 2) }], more: 0 }; + if (row.type === 'deleted') return { pairs: [{ del: truncateClean(row.leftBlock.text, maxLen * 2), ins: '' }], more: 0 }; + const pairs = extractDeltaPairs(row.wordDiffs || []); + const visible = pairs.slice(0, 3).map(p => ({ del: truncateClean(p.del, maxLen), ins: truncateClean(p.ins, maxLen) })); + return { pairs: visible, more: Math.max(0, pairs.length - visible.length) }; +} + +/* ── Final text (copy / .txt) ─────────────────────────────────────────────── */ + +/** + * The new version as plain text, including all inline edits. Header/footer lines are page + * furniture and are always left out, whether or not the header/footer filter is active. + */ +function buildFinalText() { + const blocks = state.alignedRows.length + ? state.alignedRows.filter(r => r.rightBlock).map(r => r.rightBlock) + : parseIntoBlocks(state.rightText || ''); + return blocks + .filter(b => !b.isHeaderFooter && !isInternalMarkerLine(b.text)) + .map(b => cleanExportText(b.text).trim()) + .filter(Boolean) + .join('\n\n'); +} + +/* ── HTML report ──────────────────────────────────────────────────────────── */ + +function renderReportTokensHtml(tokens) { + return tokens + .map(tok => { + const content = escapeHtml(tok.value); + if (tok.removed) { + return `<del><span class="sr">${escapeHtml(t('a11yRemovedStart'))} </span>${content}<span class="sr"> ${escapeHtml(t('a11yRemovedEnd'))}</span></del>`; + } + if (tok.added) { + return `<ins><span class="sr">${escapeHtml(t('a11yAddedStart'))} </span>${content}<span class="sr"> ${escapeHtml(t('a11yAddedEnd'))}</span></ins>`; + } + return content; }) - .join('\n'); + .join(''); +} + +function renderReportSummaryPairsHtml(row) { + const { pairs, more } = getRowChangeSummary(row); + const items = pairs.map(p => { + if (p.del && p.ins) { + return `${renderReportTokensHtml([{ value: p.del, removed: true }])} <span aria-hidden="true">→</span> ${renderReportTokensHtml([{ value: p.ins, added: true }])}`; + } + if (p.ins) return renderReportTokensHtml([{ value: p.ins, added: true }]); + return renderReportTokensHtml([{ value: p.del, removed: true }]); + }); + if (more) items.push(`<span class="more">+${more} ${escapeHtml(t('moreDeltas'))}</span>`); + return items.map(html => `<div>${html}</div>`).join(''); +} + +const REPORT_CSS = ` +:root { color-scheme: light dark; --bg:#fff; --fg:#1f2937; --muted:#6b7280; --line:#e5e7eb; --card:#f9fafb; + --del-fg:#9f1239; --del-bg:#ffe4e6; --ins-fg:#14532d; --ins-bg:#dcfce7; --mod:#1d4ed8; --link:#1d4ed8; } +@media (prefers-color-scheme: dark) { + :root { --bg:#111418; --fg:#e5e7eb; --muted:#9ca3af; --line:#2d333b; --card:#181c21; + --del-fg:#fda4af; --del-bg:#4c0519; --ins-fg:#86efac; --ins-bg:#052e16; --mod:#93c5fd; --link:#93c5fd; } +} +* { box-sizing: border-box; } +html { background: var(--bg); color: var(--fg); } +body { margin: 0 auto; max-width: 860px; padding: 32px 24px 64px; font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; overflow-wrap: anywhere; } +a { color: var(--link); } +h1 { font-size: 1.5rem; line-height: 1.3; margin: 0 0 12px; } +h2 { font-size: 1.15rem; margin: 40px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--line); } +.meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0; font-size: .9rem; } +.meta dt { color: var(--muted); } +.meta dd { margin: 0; } +.counts { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 16px 0 0; } +.counts li { border: 1px solid var(--line); background: var(--card); border-radius: 8px; padding: 8px 14px; min-width: 120px; } +.counts strong { display: block; font-size: 1.35rem; line-height: 1.2; } +.counts span { color: var(--muted); font-size: .85rem; } +.legend { font-size: .85rem; color: var(--muted); margin: 8px 0 16px; } +del { color: var(--del-fg); background: var(--del-bg); text-decoration: line-through; text-decoration-thickness: 1.5px; border-radius: 2px; padding: 0 2px; } +ins { color: var(--ins-fg); background: var(--ins-bg); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 2px; border-radius: 2px; padding: 0 2px; } +del + ins { margin-left: .25em; } +.sr { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +table { width: 100%; border-collapse: collapse; font-size: .875rem; } +caption { text-align: left; color: var(--muted); font-size: .85rem; padding-bottom: 6px; } +th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--line); } +th { font-weight: 600; color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; white-space: nowrap; } +td.num { white-space: nowrap; color: var(--muted); } +td.ref { white-space: nowrap; } +.badge { display: inline-block; font-size: .75rem; font-weight: 600; border: 1px solid currentColor; border-radius: 999px; padding: 0 8px; white-space: nowrap; } +.badge.modified { color: var(--mod); } .badge.inserted { color: var(--ins-fg); } .badge.deleted { color: var(--del-fg); } +.more { color: var(--muted); font-size: .8rem; } +.empty { padding: 16px; border: 1px dashed var(--line); border-radius: 8px; color: var(--muted); } +.doc { margin-top: 8px; } +.row { margin: 0 0 10px; padding: 2px 0 2px 14px; border-left: 3px solid transparent; } +.row.modified { border-left-color: var(--mod); } .row.inserted { border-left-color: var(--ins-fg); } .row.deleted { border-left-color: var(--del-fg); } +.row.kind-bullet, .row.kind-clause { padding-left: calc(14px + 2.2em); text-indent: -2.2em; } +.row.kind-hf { font-size: .8rem; color: var(--muted); font-style: italic; } +.row h3, .row h4, .row h5 { margin: 18px 0 4px; line-height: 1.35; } +.row h3 { font-size: 1.15rem; } .row h4 { font-size: 1.05rem; } .row h5 { font-size: 1rem; } +.row p { margin: 0; } +.row-tag { display: block; text-indent: 0; font-size: .75rem; color: var(--muted); margin-bottom: 2px; } +.row-tag a { color: inherit; } +.row:target { outline: 2px solid var(--link); outline-offset: 4px; border-radius: 2px; } +footer { margin-top: 48px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: .8rem; } +@media print { + :root { --bg:#fff; --fg:#000; --muted:#444; --line:#bbb; --card:#fff; --del-fg:#7f1d1d; --del-bg:transparent; --ins-fg:#14532d; --ins-bg:transparent; --mod:#1e3a8a; --link:#000; } + @page { size: A4; margin: 16mm 15mm 18mm; } + body { max-width: none; padding: 0; font-size: 10.5pt; } + a { text-decoration: none; } + h2 { break-after: avoid; } + tr, .row, .counts li { break-inside: avoid; } + thead { display: table-header-group; } + .row-tag a::after { content: none; } +} +`; + +function buildExportHtmlReport(date = new Date()) { + const counts = getExportCounts(); + const title = getExportTitle(); + const changeIndex = new Map(state.changeRows.map((row, i) => [row.id, i + 1])); + const anchorFor = row => `c${changeIndex.get(row.id)}`; + + const changeListHtml = state.changeRows.length + ? `<table> +<caption>${escapeHtml(t('exportChangeListCaption'))}</caption> +<thead><tr><th scope="col">#</th><th scope="col">${escapeHtml(t('exportColRef'))}</th><th scope="col">${escapeHtml(t('exportColType'))}</th><th scope="col">${escapeHtml(t('exportColChange'))}</th></tr></thead> +<tbody> +${state.changeRows + .map(row => { + const n = changeIndex.get(row.id); + const ref = getRowRefCode(row) || `#${n}`; + return `<tr><td class="num">${n}</td><td class="ref"><a href="#${anchorFor(row)}">${escapeHtml(ref)}</a><div class="more">${escapeHtml(t('pageShort'))} ${row.pageNum || 1}</div></td><td><span class="badge ${row.type}">${escapeHtml(getRowTypeLabel(row))}</span></td><td>${renderReportSummaryPairsHtml(row)}</td></tr>`; + }) + .join('\n')} +</tbody> +</table>` + : `<p class="empty">${escapeHtml(t('exportNoChanges'))}</p>`; + + const bodyRows = getExportBodyRows(); + const redlineHtml = bodyRows.length + ? bodyRows + .map(row => { + const block = row.rightBlock || row.leftBlock; + const kind = (block && block.kind) || 'p'; + const inner = renderReportTokensHtml(getExportDiffTokens(row)); + const tag = kind === 'h1' ? 'h3' : kind === 'h2' ? 'h4' : kind === 'h3' ? 'h5' : 'p'; + const isChange = row.type !== 'equal'; + const idAttr = isChange ? ` id="${anchorFor(row)}"` : ''; + const tagHtml = isChange + ? `<span class="row-tag"><a href="#changes">${escapeHtml(getRowTypeLabel(row))} · ${escapeHtml(getRowRefCode(row))}</a></span>` + : ''; + return `<div class="row ${row.type} kind-${kind}"${idAttr}>${tagHtml}<${tag}>${inner}</${tag}></div>`; + }) + .join('\n') + : `<p class="empty">${escapeHtml(t('exportNoChanges'))}</p>`; + + const csp = + "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; return `<!DOCTYPE html> -<html lang="${state.lang}"> +<html lang="${state.lang === 'de' ? 'de' : 'en'}"> <head> -<meta charset="UTF-8"> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<meta http-equiv="Content-Security-Policy" content="${csp}"> +<meta name="referrer" content="no-referrer"> +<meta name="color-scheme" content="light dark"> +<meta name="generator" content="Doc Diff"> <title>${escapeHtml(title)} - + -
+

${escapeHtml(title)}

-
${escapeHtml(t('colLeft'))}: ${escapeHtml(state.leftName || '-')} · ${escapeHtml(t('colRight'))}: ${escapeHtml(state.rightName || '-')} · ${state.changeRows.length} ${escapeHtml(t('changesLabel'))}
-
-
-${bodyRows} -
+
+
${escapeHtml(t('exportLabelOriginal'))}
${escapeHtml(getDisplayDocName('left'))}
+
${escapeHtml(t('exportLabelRevised'))}
${escapeHtml(getDisplayDocName('right'))}
+
${escapeHtml(t('exportLabelCreated'))}
+
${escapeHtml(t('exportLabelScope'))}
${escapeHtml(getExportScopeText())}
+
${escapeHtml(t('exportLabelHf'))}
${escapeHtml(getExportHfText())}
+
+
+
+
+

${escapeHtml(t('exportSummaryHeading'))}

+
    +
  • ${counts.total}${escapeHtml(t('exportCountTotal'))}
  • +
  • ${counts.modified}${escapeHtml(t('typeMod'))}
  • +
  • ${counts.inserted}${escapeHtml(t('typeIns'))}
  • +
  • ${counts.deleted}${escapeHtml(t('typeDel'))}
  • +
+
+
+

${escapeHtml(t('exportChangesHeading'))}

+${changeListHtml} +
+
+

${escapeHtml(t('exportRedlineHeading'))}

+

${escapeHtml(t('exportLegendLabel'))}: ${escapeHtml(t('exportLegendIns'))} · ${escapeHtml(t('exportLegendDel'))}${state.onlyChanges ? ` · ${escapeHtml(t('exportScopeChanges'))}` : ''}

+
+${redlineHtml} +
+
+
+
${escapeHtml(t('exportFooterNote'))}
-`; + +`; +} + +/* ── Markdown report ──────────────────────────────────────────────────────── */ + +/** Escapes inline Markdown syntax so document text can never be parsed as markup. */ +function escapeMarkdownInline(text) { + return String(text) + .replace(/[\\`*_~[\]<>|]/g, '\\$&') + .replace(/&(?=#?[A-Za-z0-9]+;)/g, '\\&'); } -function buildExportMarkdown() { - const rows = state.onlyChanges ? state.changeRows : state.alignedRows; +/** Escapes characters that would start a block construct when they begin a line. */ +function escapeMarkdownLineStart(line) { + return line + .replace(/^(\s*)([-+=>])/, '$1\\$2') + .replace(/^(\s*\d+)([.)])(?=\s|$)/, '$1\\$2'); +} + +const MD_PUNCT = /[!-/:-@[-`{-~\p{P}\p{S}]/u; +function mdCharClass(ch) { + if (!ch || /\s/.test(ch)) return 'space'; + return MD_PUNCT.test(ch) ? 'punct' : 'word'; +} + +/** + * Renders diff tokens as Markdown: deletions as ~~strike~~ and insertions as **bold**. + * Leading/trailing whitespace is moved outside the delimiters, and where CommonMark's + * flanking rules would make `~~`/`**` fail (e.g. punctuation glued to a word), the + * equivalent inline HTML / is used instead. + */ +function renderMarkdownTokens(tokens) { + const pieces = tokens.map(tok => ({ ...tok, text: escapeMarkdownInline(tok.value) })); + let out = ''; + for (let i = 0; i < pieces.length; i++) { + const p = pieces[i]; + if (!p.removed && !p.added) { + out += p.text; + continue; + } + const m = p.text.match(/^(\s*)([\s\S]*?)(\s*)$/); + const lead = m[1]; + const core = m[2]; + const trail = m[3]; + if (!core) { + if (p.added) out += p.text; + continue; + } + const prevChar = (out + lead).slice(-1); + let nextChar = ''; + if (trail) nextChar = ' '; + else if (i + 1 < pieces.length) { + const next = pieces[i + 1]; + nextChar = next.removed || next.added ? (/^\s/.test(next.text) ? ' ' : '*') : next.text.charAt(0); + } + const first = mdCharClass(core.charAt(0)); + const last = mdCharClass(core.charAt(core.length - 1)); + const opens = first !== 'punct' || mdCharClass(prevChar) !== 'word'; + const closes = last !== 'punct' || mdCharClass(nextChar) !== 'word'; + const wrapped = + opens && closes + ? p.removed + ? `~~${core}~~` + : `**${core}**` + : p.removed + ? `${core}` + : `${core}`; + // Keep a replaced passage readable: "~~old~~ **new**" rather than "~~old~~**new**". + const gap = p.added && i > 0 && pieces[i - 1].removed && !lead && !/\s$/.test(out) ? ' ' : ''; + out += gap + lead + wrapped + trail; + } + return out; +} + +function escapeMarkdownTableCell(text) { + return String(text).replace(/\s*\n\s*/g, ' '); +} + +function buildExportMarkdown(date = new Date()) { + const counts = getExportCounts(); + const changeIndex = new Map(state.changeRows.map((row, i) => [row.id, i + 1])); const lines = [ - `# Doc Diff: ${state.leftName || 'Original'} vs. ${state.rightName || 'Revised'}`, + `# ${escapeMarkdownInline(getExportTitle())}`, '', - `- **${t('colLeft')}:** ${state.leftName || '-'}`, - `- **${t('colRight')}:** ${state.rightName || '-'}`, - `- **${t('sidebarTitle')}:** ${state.changeRows.length}`, + `| ${escapeMarkdownInline(t('exportLabelDetail'))} | |`, + '| --- | --- |', + `| ${escapeMarkdownInline(t('exportLabelOriginal'))} | ${escapeMarkdownInline(getDisplayDocName('left'))} |`, + `| ${escapeMarkdownInline(t('exportLabelRevised'))} | ${escapeMarkdownInline(getDisplayDocName('right'))} |`, + `| ${escapeMarkdownInline(t('exportLabelCreated'))} | ${escapeMarkdownInline(formatExportDate(date))} |`, + `| ${escapeMarkdownInline(t('exportLabelChanges'))} | ${escapeMarkdownInline(formatExportCountsLine(counts))} |`, + `| ${escapeMarkdownInline(t('exportLabelScope'))} | ${escapeMarkdownInline(getExportScopeText())} |`, + `| ${escapeMarkdownInline(t('exportLabelHf'))} | ${escapeMarkdownInline(getExportHfText())} |`, '', - '---', + `## ${escapeMarkdownInline(t('exportChangesHeading'))}`, '', ]; - for (const row of rows) { - const primaryBlock = row.rightBlock || row.leftBlock; - const prefix = primaryBlock && primaryBlock.isHeading ? '### ' : ''; - if (row.type === 'equal') { - lines.push(`${prefix}${row.rightBlock.text}`); - } else if (row.type === 'deleted') { - lines.push(`${prefix}~~${row.leftBlock.text}~~`); - } else if (row.type === 'inserted') { - lines.push(`${prefix}**${row.rightBlock.text}**`); - } else if (row.type === 'modified') { - const mdDiff = coalesceUnifiedTokens(row.wordDiffs) - .map(p => (p.removed ? `~~${p.value}~~` : p.added ? `**${p.value}**` : p.value)) - .join(''); - lines.push(`${prefix}${mdDiff}`); + if (state.changeRows.length) { + lines.push( + `| # | ${escapeMarkdownInline(t('exportColRef'))} | ${escapeMarkdownInline(t('pageShort'))} | ${escapeMarkdownInline(t('exportColType'))} | ${escapeMarkdownInline(t('exportColChange'))} |`, + '| ---: | --- | ---: | --- | --- |' + ); + for (const row of state.changeRows) { + const n = changeIndex.get(row.id); + const { pairs, more } = getRowChangeSummary(row, 60); + const summary = pairs + .map(p => { + if (p.del && p.ins) return `${renderMarkdownTokens([{ value: p.del, removed: true }])} → ${renderMarkdownTokens([{ value: p.ins, added: true }])}`; + if (p.ins) return renderMarkdownTokens([{ value: p.ins, added: true }]); + return renderMarkdownTokens([{ value: p.del, removed: true }]); + }) + .concat(more ? [`+${more} ${escapeMarkdownInline(t('moreDeltas'))}`] : []) + .join('
'); + lines.push( + `| ${n} | ${escapeMarkdownInline(getRowRefCode(row) || `#${n}`)} | ${row.pageNum || 1} | ${escapeMarkdownInline(getRowTypeLabel(row))} | ${escapeMarkdownTableCell(summary)} |` + ); + } + } else { + lines.push(escapeMarkdownInline(t('exportNoChanges'))); + } + + lines.push( + '', + `## ${escapeMarkdownInline(t('exportRedlineHeading'))}`, + '', + `> ${escapeMarkdownInline(t('exportLegendLabel'))}: **${escapeMarkdownInline(t('exportLegendIns'))}** · ~~${escapeMarkdownInline(t('exportLegendDel'))}~~${state.onlyChanges ? ` · ${escapeMarkdownInline(t('exportScopeChanges'))}` : ''}`, + '' + ); + + for (const row of getExportBodyRows()) { + const block = row.rightBlock || row.leftBlock; + const kind = (block && block.kind) || 'p'; + const body = renderMarkdownTokens(getExportDiffTokens(row)); + if (!body.trim()) continue; + const prefix = kind === 'h1' ? '### ' : kind === 'h2' ? '#### ' : kind === 'h3' ? '##### ' : ''; + const line = prefix ? prefix + body : escapeMarkdownLineStart(body); + if (row.type !== 'equal') { + const n = changeIndex.get(row.id); + lines.push(`[${n}] ${escapeMarkdownInline(getRowTypeLabel(row))} · ${escapeMarkdownInline(getRowRefCode(row))}`, ''); } - lines.push(''); + lines.push(kind === 'hf' ? `${line}` : line, ''); } + + lines.push('---', '', `*${escapeMarkdownInline(t('exportFooterNote'))}*`, ''); return lines.join('\n'); } +/* ── Print / PDF ──────────────────────────────────────────────────────────── */ + +function cssString(str) { + return `"${String(str).replace(/[\\"]/g, '\\$&').replace(/[\n\r\f]/g, ' ')}"`; +} + +function renderPrintCellHtml(html, block) { + if (!block) return ''; + const kind = block.kind || 'p'; + return `${html}`; +} + +/** Builds a print-only, paginated rendering of the comparison for the current view mode. */ +function buildPrintViewHtml(date = new Date()) { + const rows = getExportBodyRows(); + const counts = getExportCounts(); + const mode = state.viewMode; + const head = ` +
+
${escapeHtml(getExportTitle())}
+
+ ${escapeHtml(t('exportLabelOriginal'))}: ${escapeHtml(getDisplayDocName('left'))} + ${escapeHtml(t('exportLabelRevised'))}: ${escapeHtml(getDisplayDocName('right'))} + ${escapeHtml(t('exportLabelCreated'))}: ${escapeHtml(formatExportDate(date))} +
+
+ ${escapeHtml(formatExportCountsLine(counts))} + ${escapeHtml(getExportScopeText())} + ${escapeHtml(t('exportLabelHf'))}: ${escapeHtml(getExportHfText())} +
+
${escapeHtml(t('exportLegendLabel'))}: ${escapeHtml(t('exportLegendIns'))} ${escapeHtml(t('exportLegendDel'))}
+
`; + + const rowClass = row => `pv-row pv-${row.type}`; + const tagHtml = row => + row.type === 'equal' + ? '' + : `${escapeHtml(getRowTypeLabel(row))} · ${escapeHtml(getRowRefCode(row))}`; + + const groups = []; + if (mode === 'pages') { + const byPage = new Map(); + for (const row of rows) { + const p = row.pageNum || 1; + if (!byPage.has(p)) byPage.set(p, []); + byPage.get(p).push(row); + } + const totalPages = new Set(state.alignedRows.map(r => r.pageNum || 1)).size || 1; + for (const [p, list] of byPage) { + groups.push({ label: `${t('pageLabel')} ${p} ${t('pageOf')} ${totalPages}`, rows: list }); + } + } else { + groups.push({ label: '', rows }); + } + + let body; + if (mode === 'inline') { + body = groups + .map( + g => `
${g.rows + .map(row => { + const block = row.rightBlock || row.leftBlock; + return `
${tagHtml(row)}${renderUnifiedBlockHtml(row, '', true)}
`; + }) + .join('')}
` + ) + .join(''); + } else { + const thead = `${escapeHtml(t('colLeft'))} · ${escapeHtml(getDisplayDocName('left'))}${escapeHtml(t('colRight'))} · ${escapeHtml(getDisplayDocName('right'))}`; + // One table (the column header repeats on every printed page); in "pages" mode each original + // page becomes its own introduced by a page label that is kept with its first row. + body = `${thead}${groups + .map( + g => `${ + g.label ? `` : '' + }${g.rows + .map( + row => + `${renderPrintCellHtml( + (row.type === 'deleted' || row.type === 'modified' ? tagHtml(row) : '') + renderLeftBlockHtml(row, ''), + row.leftBlock + )}${renderPrintCellHtml( + (row.type === 'inserted' || row.type === 'modified' ? tagHtml(row) : '') + renderRightBlockHtml(row, ''), + row.rightBlock + )}` + ) + .join('')}` + ) + .join('')}
${escapeHtml(g.label)}
`; + } + + const emptyHtml = rows.length ? '' : `

${escapeHtml(t('exportNoChanges'))}

`; + return `${head}
${body}${emptyHtml}
`; +} + +function preparePrintView() { + if (!state.alignedRows.length) return; + const date = new Date(); + let view = document.getElementById('print-view'); + if (!view) { + view = document.createElement('div'); + view.id = 'print-view'; + view.className = 'print-view'; + view.setAttribute('aria-hidden', 'true'); + document.body.appendChild(view); + } + setSafeContainerHtml(view, buildPrintViewHtml(date)); + + let pageStyle = document.getElementById('print-page-style'); + if (!pageStyle) { + pageStyle = document.createElement('style'); + pageStyle.id = 'print-page-style'; + document.head.appendChild(pageStyle); + } + const footerLeft = `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; + const orientation = state.viewMode === 'inline' ? 'portrait' : 'landscape'; + pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: 14mm 12mm 16mm; + @bottom-left { content: ${cssString(footerLeft)}; font: 7.5pt sans-serif; color: #444; } + @bottom-right { content: ${cssString(`${t('pageLabel')} `)} counter(page) ${cssString(` ${t('pageOf')} `)} counter(pages); font: 7.5pt sans-serif; color: #444; } } }`; + document.body.classList.add('has-print-view'); +} + +function cleanupPrintView() { + document.body.classList.remove('has-print-view'); + const view = document.getElementById('print-view'); + if (view) view.remove(); +} + +if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('beforeprint', preparePrintView); + window.addEventListener('afterprint', cleanupPrintView); +} + +/* ── Copy to clipboard ────────────────────────────────────────────────────── */ + let copyLabelTimer = null; function copyFinalTextToClipboard() { - const finalText = state.alignedRows - .filter(r => r.rightBlock && !r.rightBlock.isHeaderFooter) - .map(r => r.rightBlock.text) - .join('\n\n'); + const finalText = buildFinalText(); const flashCopied = () => { const labelEl = document.getElementById('i18n-btn-export'); @@ -6440,69 +7660,164 @@

${escapeHtml(title)}

}, 1800); }; - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(finalText).then(flashCopied).catch(() => { - const ta = document.createElement('textarea'); - ta.value = finalText; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - flashCopied(); - }); - } else { + const legacyCopy = () => { const ta = document.createElement('textarea'); ta.value = finalText; + ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(ta); + } flashCopied(); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(finalText).then(flashCopied).catch(legacyCopy); + } else { + legacyCopy(); } } +/* ── Dispatcher ───────────────────────────────────────────────────────────── */ + function handleExportAction(type) { + // Commit an inline edit that is still open so the export reflects it. + const openEditor = document.querySelector('.clause-inline-editor'); + if (openEditor) openEditor.blur(); + if (type === 'print') { + if (!state.alignedRows.length) return; + preparePrintView(); window.print(); return; } - if (!state.alignedRows.length) { - const sample = SAMPLES[state.lang] || SAMPLES.de; - state.leftName = t('sampleLeftName'); - state.leftFormat = 'DEMO'; - state.leftText = sample.v1; - state.rightName = t('sampleRightName'); - state.rightFormat = 'DEMO'; - state.rightText = sample.v2; - runComparison(); - } + + // Exports need a finished comparison. The menu is disabled until then; never substitute + // sample content for (or overwrite) a document the user has loaded. + if (!state.alignedRows.length) return; + if (type === 'copy') { copyFinalTextToClipboard(); - return; - } - const baseName = `DocDiff_${(state.rightName || 'Comparison').replace(/\.[^.]+$/, '')}`; - if (type === 'txt') { - const finalText = state.alignedRows - .filter(r => r.rightBlock && !r.rightBlock.isHeaderFooter) - .map(r => r.rightBlock.text) - .join('\n\n'); - downloadBlob(finalText, 'text/plain;charset=utf-8', `${baseName}_Neu.txt`); + } else if (type === 'txt') { + downloadBlob(`${buildFinalText()}\n`, 'text/plain;charset=utf-8', buildExportFileName('exportFileFinal', 'txt')); } else if (type === 'word') { - const html = buildExportHtmlDocument(true); - downloadBlob('\ufeff' + html, 'application/msword;charset=utf-8', `${baseName}_Redline.doc`); + downloadBlob( + buildDocxRedline(), + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + buildExportFileName('exportFileRedline', 'docx') + ); } else if (type === 'html') { - const html = buildExportHtmlDocument(false); - downloadBlob(html, 'text/html;charset=utf-8', `${baseName}_Report.html`); + downloadBlob(buildExportHtmlReport(), 'text/html;charset=utf-8', buildExportFileName('exportFileReport', 'html')); } else if (type === 'md') { - const md = buildExportMarkdown(); - downloadBlob(md, 'text/markdown;charset=utf-8', `${baseName}_Report.md`); + downloadBlob(buildExportMarkdown(), 'text/markdown;charset=utf-8', buildExportFileName('exportFileReport', 'md')); } } +/* ── Export menu: availability and keyboard support ──────────────────────── */ + +/** Really disables the export triggers (not just visually) while no comparison is available. */ +function syncExportAvailability() { + const available = document.body.classList.contains('is-comparing') && state.alignedRows.length > 0; + for (const id of ['btn-export-menu', 'mob-btn-export']) { + const btn = document.getElementById(id); + if (!btn) continue; + btn.disabled = !available; + btn.setAttribute('aria-disabled', String(!available)); + } + if (!available) closeExportMenu(false); +} + +function getExportMenuTrigger() { + const mob = document.getElementById('mob-btn-export'); + if (mob && mob.getAttribute('aria-expanded') === 'true') return mob; + return document.getElementById('btn-export-menu'); +} + +function closeExportMenu(returnFocus) { + const menu = document.getElementById('export-menu'); + if (!menu || !menu.classList.contains('is-open')) return; + const trigger = getExportMenuTrigger(); + menu.classList.remove('is-open'); + for (const id of ['btn-export-menu', 'mob-btn-export']) { + const btn = document.getElementById(id); + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + document.body.classList.remove('mobile-sheet-open'); + if (returnFocus && trigger) trigger.focus(); +} + +function initExportMenuKeyboard() { + const menu = document.getElementById('export-menu'); + if (!menu) return; + const items = () => Array.from(menu.querySelectorAll('[role="menuitem"]:not([disabled])')); + items().forEach(item => item.setAttribute('tabindex', '-1')); + + // Menu-button pattern: opening the menu moves focus to the first item. + for (const id of ['btn-export-menu', 'mob-btn-export']) { + const trigger = document.getElementById(id); + if (!trigger) continue; + trigger.setAttribute('aria-haspopup', 'menu'); + trigger.setAttribute('aria-controls', 'export-menu'); + trigger.addEventListener('click', () => { + setTimeout(() => { + if (menu.classList.contains('is-open')) { + const first = items()[0]; + if (first) first.focus(); + } + }, 0); + }); + trigger.addEventListener('keydown', e => { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; + e.preventDefault(); + if (!menu.classList.contains('is-open')) trigger.click(); + setTimeout(() => { + const list = items(); + const target = e.key === 'ArrowUp' ? list[list.length - 1] : list[0]; + if (target) target.focus(); + }, 0); + }); + } + + menu.addEventListener('keydown', e => { + const list = items(); + if (!list.length) return; + const idx = list.indexOf(document.activeElement); + let next = null; + if (e.key === 'ArrowDown') next = list[(idx + 1) % list.length]; + else if (e.key === 'ArrowUp') next = list[(idx - 1 + list.length) % list.length]; + else if (e.key === 'Home') next = list[0]; + else if (e.key === 'End') next = list[list.length - 1]; + else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeExportMenu(true); + return; + } else if (e.key === 'Tab') { + closeExportMenu(false); + return; + } else { + return; + } + e.preventDefault(); + e.stopPropagation(); + next.focus(); + }); +} + +if (typeof document !== 'undefined' && document.body && typeof MutationObserver !== 'undefined') { + initExportMenuKeyboard(); + syncExportAvailability(); + new MutationObserver(syncExportAvailability).observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); +} + async function extractPdfText(arrayBuffer) { const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; const pageSections = []; diff --git a/README.md b/README.md index 0da0e71..7217222 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,13 @@ Doc Diff is built so that any data protection officer or security auditor can ve - **Delta-Only Change Inspector & Click-to-Jump Rail:** The right side rail extracts only the exact modified tokens (`145.000,00 → 168.500,00`, `12.03.2026 → 18.04.2026`, `schriftlich → in Textform`) with section-aware clause references (`§ 1 (1)`, `§ 2 (1)`), category filter tabs (`Alle`, `Geändert`, `Neu`, `Entfernt`), 1-click jump-to-clause, and inline edit/revert actions. - **Direct Inline Clause Editing, 1-Click Revert & Undo/Redo:** Double-click or click **Bearbeiten** (`Edit`) on any clause in the revised or unified pane to edit its text directly in place (`Enter` to apply, `Esc` to cancel) and re-run the diff in `< 5 ms`. Click **Vorher übernehmen** (`Use old`) to revert any clause back to the baseline version, with full **Undo / Redo** history (`Cmd+Z` / `Shift+Cmd+Z` or toolbar buttons). - **Multi-Format Client-Side Export:** Export comparisons locally without any server roundtrip: - - **Copy Final Text:** 1-click copy of the clean, edited target text to the clipboard + - **Copy Final Text:** 1-click copy of the clean, edited target text to the clipboard (header/footer lines left out) - **Save Clean Text (`.txt`):** Download the final revised document text - - **PDF / Print (`Cmd+P` / `Ctrl+P`):** Clean two-column or unified redline print layout - - **Word Redline (`.doc`):** Formatted Word-compatible redline document with `` strikethroughs and `` insertions - - **HTML Report (`.html`):** Standalone, read-only HTML diff report for archiving or sharing - - **Markdown (`.md`):** Clean Markdown diff summary (`~~deleted~~` / `**inserted**`) + - **PDF / Print (`Cmd+P` / `Ctrl+P`):** Dedicated print layout for all three view modes, with both file names, date, page numbers, and greyscale-safe marks (strike-through / underline) + - **Word Redline (`.docx`):** A genuine Word document generated in the browser, with every difference recorded as a tracked change (author "Doc Diff"), so you can **Accept / Reject** each change in Word or LibreOffice + - **HTML Report (`.html`):** Self-contained, accessible, read-only report with summary counts, a linked change list, and the full redline; strict CSP, print- and dark-mode-friendly + - **Markdown (`.md`):** Summary table with clause references plus the redline (`~~deleted~~` / `**inserted**`), with document text safely escaped + - File names combine both documents, e.g. `Vertrag_v2_vs_v1_Redline.docx`, and are sanitized for Windows, macOS, and Linux - **Keyboard-First Navigation & System-Aware Theme:** - `J` / `K` — Jump to next / previous change - `/` — Focus full-text search diff --git a/scripts/check.mjs b/scripts/check.mjs index 1e1ba64..ee37973 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -119,3 +119,6 @@ for (const siteFile of ['CNAME', 'og-image.png', 'impressum.html', 'datenschutz. assert.equal(fs.readFileSync(path.join(root, 'site', 'CNAME'), 'utf8').trim(), 'docdiff.app', 'CNAME must be docdiff.app'); console.log(`✓ All security, privacy, legal, standalone, and i18n checks passed (${deKeys.length} i18n keys verified).`); + +// 6. Unit tests for the pure export builders (docx, HTML, Markdown, file names); fast and dependency-free. +await import('./test-export.mjs'); diff --git a/scripts/test-export.mjs b/scripts/test-export.mjs new file mode 100644 index 0000000..6cf5094 --- /dev/null +++ b/scripts/test-export.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/** + * Unit tests for the pure export builders (docx, HTML report, Markdown, final text, file names). + * Loads the app sources into a `node:vm` context with minimal stubs; no browser, no dependencies. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = rel => fs.readFileSync(path.join(root, rel), 'utf8'); + +const sources = [ + 'src/js/01-i18n.js', + 'src/js/02-samples.js', + 'src/js/04-core.js', + 'src/js/05-render.js', + 'src/js/06-compare.js', + 'src/js/08-export-docx.js', + 'src/js/08-export.js', +]; + +const harness = ` +const state = { + lang: 'de', leftName: '', rightName: '', leftText: '', rightText: '', + alignedRows: [], changeRows: [], onlyChanges: false, ignoreHeadersFooters: true, + filteredHfCount: 0, viewMode: 'split', +}; +function t(key) { return I18N[state.lang][key] || I18N.de[key] || key; } +function compare(leftText, rightText, opts = {}) { + Object.assign(state, { onlyChanges: false, ignoreHeadersFooters: true }, opts); + const l = parseIntoBlocks(leftText); + const r = parseIntoBlocks(rightText); + state.filteredHfCount = Math.max(l.filter(b => b.isHeaderFooter).length, r.filter(b => b.isHeaderFooter).length); + const lf = state.ignoreHeadersFooters ? l.filter(b => !b.isHeaderFooter) : l; + const rf = state.ignoreHeadersFooters ? r.filter(b => !b.isHeaderFooter) : r; + state.leftText = leftText; + state.rightText = rightText; + state.alignedRows = alignDocumentBlocks(lf, rf); + state.changeRows = state.alignedRows.filter(row => row.type !== 'equal'); +} +`; + +const context = { + console, + TextEncoder, + URL, + Intl, + navigator: { language: 'de-DE', languages: ['de-DE'] }, +}; +context.window = context; +context.globalThis = context; +vm.createContext(context); +vm.runInContext(read('vendor/libs/diff.min.js'), context, { filename: 'diff.min.js' }); +vm.runInContext( + sources.map(read).join('\n') + harness + '\nglobalThis.__state = state; globalThis.__compare = compare; globalThis.SAMPLES = SAMPLES;', + context, + { filename: 'export-bundle.js' } +); +const api = context; +const state = api.__state; + +/* ── Minimal ZIP reader (STORE only) ─────────────────────────────────────── */ +function readZip(bytes) { + const buf = Buffer.from(bytes); + const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + assert.ok(eocd >= 0, 'ZIP: end of central directory found'); + const count = buf.readUInt16LE(eocd + 10); + let pos = buf.readUInt32LE(eocd + 16); + const files = new Map(); + for (let i = 0; i < count; i++) { + assert.equal(buf.readUInt32LE(pos), 0x02014b50, 'ZIP: central directory signature'); + const method = buf.readUInt16LE(pos + 10); + const crc = buf.readUInt32LE(pos + 16); + const size = buf.readUInt32LE(pos + 20); + const nameLen = buf.readUInt16LE(pos + 28); + const offset = buf.readUInt32LE(pos + 42); + const name = buf.toString('utf8', pos + 46, pos + 46 + nameLen); + assert.equal(method, 0, 'ZIP: STORE method'); + assert.equal(buf.readUInt32LE(offset), 0x04034b50, 'ZIP: local header signature'); + const localNameLen = buf.readUInt16LE(offset + 26); + const dataStart = offset + 30 + localNameLen + buf.readUInt16LE(offset + 28); + const data = buf.subarray(dataStart, dataStart + size); + assert.equal(api.crc32(data), crc, `ZIP: CRC-32 of ${name}`); + files.set(name, data.toString('utf8')); + pos += 46 + nameLen; + } + return files; +} + +/** Text of each body paragraph after accepting (or rejecting) all tracked changes. */ +function resolveRevisions(documentXml, mode) { + const body = documentXml.split('')[1]; + const paragraphs = body.match(/[\s\S]*?<\/w:p>/g) || []; + const out = []; + for (let p of paragraphs) { + const markDeleted = /.*?.*?]*>[\s\S]*?<\/w:del>/g, '') + : p.replace(/]*>[\s\S]*?<\/w:ins>/g, '').replace(/w:delText/g, 'w:t'); + const text = [...p.matchAll(/]*)?>([^<]*)<\/w:t>|/g)] + .map(m => (m[0] === '' ? ' ' : m[1])) + .join('') + .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/&/g, '&'); + out.push(text); + } + return out; +} + +const normalizeSpace = s => s.replace(/\s+/g, ' ').trim(); +let checks = 0; + +/* ── File names ───────────────────────────────────────────────────────────── */ +assert.equal(api.sanitizeFileNamePart('C:\\Users\\x\\Vertrag: "v2"?.pdf'), 'Vertrag_v2'); +assert.equal(api.sanitizeFileNamePart('../../etc/passwd'), 'passwd'); +assert.equal(api.sanitizeFileNamePart('CON.txt'), '_CON'); +assert.equal(api.sanitizeFileNamePart(' ... ', 'Fallback'), 'Fallback'); +assert.equal(api.sanitizeFileNamePart('ac|d*e.docx'), 'a_b_c_d_e'); +assert.equal(api.buildExportBaseName('Vertrag_v1.pdf', 'Vertrag_v2.pdf'), 'Vertrag_v2_vs_v1'); +assert.equal(api.buildExportBaseName('Alt.docx', 'Neu.docx'), 'Neu_vs_Alt'); +assert.equal(api.buildExportBaseName('', ''), 'DocDiff'); +assert.equal(api.buildExportBaseName('Same.pdf', 'same.PDF'), 'same'); +assert.ok(Array.from(api.buildExportBaseName('x'.repeat(300), 'y'.repeat(300))).length <= 130); +checks += 10; + +/* ── Samples in both languages, with and without header/footer filter ─────── */ +for (const lang of ['de', 'en']) { + for (const ignoreHeadersFooters of [true, false]) { + state.lang = lang; + state.leftName = api.t('sampleLeftName'); + state.rightName = api.t('sampleRightName'); + const sample = api.SAMPLES[lang]; + api.__compare(sample.v1, sample.v2, { ignoreHeadersFooters }); + assert.ok(state.changeRows.length > 3, 'sample has changes'); + const label = `${lang}/hf=${ignoreHeadersFooters}`; + + // Tokens reproduce both versions exactly. + for (const row of state.alignedRows) { + const tokens = api.getExportDiffTokens(row); + const oldText = tokens.filter(x => !x.added).map(x => x.value).join(''); + const newText = tokens.filter(x => !x.removed).map(x => x.value).join(''); + if (row.leftBlock) assert.equal(oldText, row.leftBlock.text, `${label}: old text of ${row.id}`); + if (row.rightBlock) assert.equal(newText, row.rightBlock.text, `${label}: new text of ${row.id}`); + checks += 2; + } + + // DOCX: valid ZIP, required parts, accept-all == new version, reject-all == old version. + const files = readZip(api.buildDocxRedline(new Date('2026-10-22T12:00:00Z'))); + for (const part of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/styles.xml', 'word/settings.xml', 'word/_rels/document.xml.rels', 'word/footer1.xml', 'docProps/core.xml', 'docProps/app.xml']) { + assert.ok(files.has(part), `${label}: docx part ${part}`); + assert.ok(files.get(part).startsWith('/); + assert.match(doc, //); + assert.match(doc, //); + const ids = [...doc.matchAll(/ m[1]); + assert.equal(new Set(ids).size, ids.length, `${label}: revision ids are unique`); + const metaParagraphs = 1 + 5 + 1; + const accepted = resolveRevisions(doc, 'accept').slice(metaParagraphs).map(normalizeSpace); + const rejected = resolveRevisions(doc, 'reject').slice(metaParagraphs).map(normalizeSpace); + // Arrays created inside the vm realm have a different prototype; copy them for deepEqual. + const expectNew = [...state.alignedRows.filter(r => r.rightBlock).map(r => normalizeSpace(r.rightBlock.text))]; + const expectOld = [...state.alignedRows.filter(r => r.leftBlock).map(r => normalizeSpace(r.leftBlock.text))]; + assert.deepEqual(accepted, expectNew, `${label}: Accept all yields the new version`); + assert.deepEqual(rejected, expectOld, `${label}: Reject all yields the old version`); + + // HTML report. + for (const onlyChanges of [false, true]) { + state.onlyChanges = onlyChanges; + const html = api.buildExportHtmlReport(new Date('2026-10-22T12:00:00Z')); + assert.match(html, new RegExp(``)); + assert.match(html, /${api.t('a11yRemovedStart')} `)); + const tagBalance = tag => (html.match(new RegExp(`<${tag}>`, 'g')) || []).length === (html.match(new RegExp(``, 'g')) || []).length; + assert.ok(tagBalance('del') && tagBalance('ins'), 'balanced del/ins'); + state.onlyChanges = false; + } + + // Markdown report. + const md = api.buildExportMarkdown(new Date('2026-10-22T12:00:00Z')); + assert.ok(!/\*\*\s|\s\*\*(?=[^\s*|]|$)/.test(md.replace(/^\|.*$/gm, '').replace(/\*\*[^*\s](?:[^*]*[^*\s])?\*\*/g, '')), `${label}: no whitespace-padded ** emphasis`); + assert.ok(!/~~\s|\s~~(?!\s)/.test(md.replace(/~~[^~\s](?:[^~]*[^~\s])?~~/g, '')), `${label}: no whitespace-padded ~~ strike`); + assert.ok(md.includes(`| # | ${api.t('exportColRef')} |`), 'markdown summary table'); + + // Final text: no header/footer lines, no internal markers. + const finalText = api.buildFinalText(); + assert.ok(!/\[\[HF\]\]|===\s*\[(SEITE|PAGE)/.test(finalText)); + for (const row of state.alignedRows) { + if (row.rightBlock && row.rightBlock.isHeaderFooter) assert.ok(!finalText.includes(row.rightBlock.text)); + } + for (const out of [md, doc, finalText]) { + assert.ok(!/\[\[HF\]\]|===\s*\[(?:SEITE|PAGE)/.test(out), `${label}: no internal markers`); + } + checks += 20; + } +} + +/* ── Markdown escaping & whitespace edge cases ───────────────────────────── */ +state.lang = 'en'; +const mdTokens = api.renderMarkdownTokens; +assert.equal(mdTokens([{ value: 'a ' }, { value: ' b ', added: true }, { value: 'c' }]), 'a **b** c'); +assert.equal(mdTokens([{ value: 'x' }, { value: ' old', removed: true }, { value: ' new', added: true }, { value: ' y' }]), 'x ~~old~~ **new** y'); +assert.equal(mdTokens([{ value: 'Euro' }, { value: ', zzgl.', added: true }]), 'Euro, zzgl.'); +assert.equal(mdTokens([{ value: 'a **b** ~~c~~ ' }]), 'a \\*\\*b\\*\\* \\~\\~c\\~\\~ \\'); +assert.equal(mdTokens([{ value: ' ', removed: true }]), ''); +assert.equal(api.escapeMarkdownLineStart('1. Item'), '1\\. Item'); +assert.equal(api.escapeMarkdownLineStart('- Item'), '\\- Item'); +api.__compare('Intro\n\nPrice is **10** EUR ~~net~~.', 'Intro\n\nPrice is **12** EUR ~~net~~ | x.'); +const md2 = api.buildExportMarkdown(new Date()); +assert.ok(md2.includes('\\*\\*') && md2.includes('\\~\\~') && md2.includes('\\|'), 'document text is escaped'); +checks += 8; + +/* ── Internal markers never leak ─────────────────────────────────────────── */ +const fakeRow = { + id: 'row-x', type: 'modified', + leftBlock: { text: '[[HF]] Seite 1' }, rightBlock: { text: '=== [SEITE 2] === Seite 2' }, + wordDiffs: [{ value: '[[HF]] Seite 1', removed: true }, { value: '=== [SEITE 2] === Seite 2', added: true }], +}; +const fakeTokens = api.getExportDiffTokens(fakeRow).map(x => x.value).join(''); +assert.ok(!fakeTokens.includes('[[HF]]') && !fakeTokens.includes('==='), 'markers stripped from tokens'); +checks += 1; + +/* ── ZIP writer handles non-ASCII names and binary data ───────────────────── */ +const zipped = readZip(api.buildZipArchive([{ name: 'ä/ö.txt', data: 'Grüße' }, { name: 'b.bin', data: new Uint8Array([0, 255, 1]) }])); +assert.equal(zipped.get('ä/ö.txt'), 'Grüße'); +assert.equal(api.crc32(new TextEncoder().encode('123456789')), 0xcbf43926, 'CRC-32 check value'); +checks += 2; + +console.log(`✓ Export builder tests passed (${checks} check groups).`); diff --git a/site/index.html b/site/index.html index 4986aba..0ad2711 100644 --- a/site/index.html +++ b/site/index.html @@ -3092,6 +3092,181 @@ } } +/* ── Print view (built by preparePrintView() on beforeprint / "PDF / Print") ── + A dedicated, row-aligned rendering for paper: no UI chrome, a header with both file names + and the date, page numbers in the page margin, and redline marks that survive greyscale + printing (strike-through for removed text, underline for added text). */ +#print-view { + display: none; +} + +@media print { + body.has-print-view > :not(#print-view) { + display: none !important; + } + body.has-print-view #print-view { + display: block !important; + font-family: "Geist", -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + font-size: 9.5pt; + line-height: 1.45; + color: #000; + background: #fff; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + #print-view .pv-head { + border-bottom: 1.5pt solid #000; + padding-bottom: 6pt; + margin-bottom: 10pt; + break-after: avoid; + } + #print-view .pv-title { + font-size: 14pt; + font-weight: 700; + margin-bottom: 4pt; + } + #print-view .pv-meta { + display: flex; + flex-wrap: wrap; + gap: 2pt 14pt; + font-size: 8.5pt; + color: #333; + } + #print-view .pv-legend { + margin-top: 4pt; + font-size: 8.5pt; + color: #333; + } + #print-view del.tok-del, + #print-view ins.tok-ins { + padding: 0 1pt; + border-radius: 1pt; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + } + #print-view del.tok-del { + color: #8a0021; + background: #ffe3e8; + text-decoration: line-through; + text-decoration-thickness: 1pt; + } + #print-view ins.tok-ins { + color: #0d5a26; + background: #dcf7e4; + text-decoration: underline; + text-decoration-thickness: 1pt; + text-underline-offset: 1.5pt; + font-weight: 600; + } + #print-view .pv-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + } + #print-view .pv-page-head { + break-after: avoid; + page-break-after: avoid; + } + #print-view .pv-table thead { + display: table-header-group; + } + #print-view .pv-table thead th { + text-align: left; + font-size: 8pt; + font-weight: 600; + color: #333; + padding: 3pt 6pt; + border-bottom: 1pt solid #888; + overflow-wrap: anywhere; + } + #print-view .pv-page-head th { + text-align: left; + font-size: 8pt; + font-weight: 600; + color: #555; + padding: 6pt 6pt 2pt; + } + #print-view .pv-row, + #print-view .pv-table tr { + break-inside: avoid; + page-break-inside: avoid; + } + #print-view .pv-cell { + vertical-align: top; + padding: 3pt 6pt; + border-bottom: 0.5pt solid #ddd; + overflow-wrap: anywhere; + hyphens: auto; + } + #print-view .pv-cell + .pv-cell { + border-left: 0.5pt solid #bbb; + } + #print-view .pv-empty { + background: repeating-linear-gradient(135deg, transparent 0 4pt, #eee 4pt 5pt); + } + #print-view .pv-cell.kind-h1, + #print-view .pv-row.kind-h1 { + font-size: 12pt; + font-weight: 700; + } + #print-view .pv-cell.kind-h2, + #print-view .pv-row.kind-h2 { + font-size: 10.5pt; + font-weight: 700; + break-after: avoid; + } + #print-view .pv-cell.kind-h3, + #print-view .pv-row.kind-h3 { + font-weight: 600; + break-after: avoid; + } + #print-view .pv-cell.kind-hf, + #print-view .pv-row.kind-hf { + font-size: 7.5pt; + font-style: italic; + color: #555; + } + #print-view .clause-marker { + display: inline-block; + min-width: 2.2em; + } + #print-view .clause-body { + display: inline; + } + #print-view .pv-tag { + display: block; + font-size: 7pt; + font-weight: 600; + font-style: normal; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #444; + } + #print-view .pv-modified > .pv-cell, + #print-view .pv-inline .pv-modified { + border-left: 2pt solid #1d4ed8; + } + #print-view .pv-deleted > .pv-cell:first-child, + #print-view .pv-inline .pv-deleted { + border-left: 2pt dashed #8a0021; + } + #print-view .pv-inserted > .pv-cell:last-child, + #print-view .pv-inline .pv-inserted { + border-left: 2pt double #0d5a26; + } + #print-view .pv-inline .pv-row { + padding: 2pt 0 2pt 8pt; + margin-bottom: 5pt; + } + #print-view .pv-inline .pv-equal { + border-left: 2pt solid transparent; + } + #print-view .pv-empty-note { + font-style: italic; + color: #444; + } +} + @@ -3271,9 +3446,9 @@
- - - + - -
-
+
- 1 · Vorher + Alte Fassung Kein Ausgangsdokument
@@ -3221,15 +3737,15 @@
-
-
- 2 · Nachher + Neue Fassung Kein neues Dokument
- J / K + J / K
- + + + +
+

+ durchgestrichen = entfernt + grün = neu + blauer Rand = Absatz geändert + J / K = nächste / vorherige Änderung + Pfeile unten = nächste / vorherige Änderung +

+ +
-
-
+
+

Doc Diff

+
-
1 · Vorher (Alter Stand) hier ablegen (PDF / DOCX / TXT)
+
Alte Fassung hier ablegen
@@ -3324,22 +3855,141 @@ -
+
-
2 · Nachher (Neuer Stand) hier ablegen (PDF / DOCX / TXT)
+
Neue Fassung hier ablegen
-
+
- -
+ +
+
+
+

Sehen Sie Wort für Wort, was sich zwischen zwei Fassungen geändert hat.

+

Für Verträge, Ausschreibungen und andere Dokumente. Ihre Dateien verlassen dabei nie Ihren Computer.

+
    +
  • Kostenlos & Open Source (MIT)
  • +
  • Keine Uploads – alles läuft lokal im Browser
  • +
  • Keine Werbung, kein Tracking, keine Cookies
  • +
+ +
+
+ + +
+
+ + +
+
+

Sie können auch beide Dateien auf einmal hierher ziehen – die erste gilt als alte Fassung.

+ +
+ + +
+
+ +
+

So funktioniert’s

+
    +
  1. Zwei Fassungen wählen Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
  2. +
  3. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Die Liste rechts führt von Änderung zu Änderung.
  4. +
  5. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit markierten Änderungen, als PDF oder als Bericht speichern.
  6. +
+
+ +
+

Was es kann – und was nicht

+
+
+

Kann

+
    +
  • PDFs mit Textebene, Word-Dateien (.docx) und reinen Text vergleichen
  • +
  • Änderungen Wort für Wort zeigen – auch geänderte Zahlen, Daten und Beträge
  • +
  • Seiten wie im Original anzeigen; wiederkehrende Kopf- und Fußzeilen werden ausgeblendet
  • +
  • Ohne Internet arbeiten – als einzelne HTML-Datei
  • +
+
+
+

Kann nicht

+
    +
  • Gescannte PDFs lesen – es gibt keine Texterkennung (OCR)
  • +
  • Alte Word-Dateien (.doc) öffnen – bitte vorher als .docx speichern
  • +
  • Formatierung vergleichen – Fett, Kursiv, Tabellenlayout und Bilder werden ignoriert, verglichen wird nur der Text
  • +
  • Verschobene Absätze erkennen – sie erscheinen als entfernt und an anderer Stelle neu
  • +
+
+
+
+ +
+

Häufige Fragen

+
+ Wie kann ich prüfen, dass nichts hochgeladen wird? +
+

Die Seite enthält eine Sicherheitsregel (Content-Security-Policy), mit der Ihr Browser jede Netzwerkverbindung aus der Seite heraus sperrt. Ihre Dateien werden nur im Arbeitsspeicher dieses Tabs gelesen und beim Schließen verworfen.

+

So prüfen Sie es selbst: Öffnen Sie die Entwicklerwerkzeuge Ihres Browsers (F12), wählen Sie den Reiter „Netzwerk“ und laden Sie dann ein Dokument – es erscheint keine Anfrage. Oder laden Sie die Offline-Datei herunter, trennen Sie die Internetverbindung und öffnen Sie sie: Alles funktioniert weiter.

+
+
+
+ Kann ich Doc Diff offline nutzen? +
+

Ja. Laden Sie DocDiff.html herunter (eine einzelne Datei, etwa 2,5 MB) und öffnen Sie sie per Doppelklick im Browser – ohne Installation und ohne Internet.

+
+
+
+ Wer steckt dahinter? Wo ist der Quellcode? +
+

Doc Diff ist ein privates Open-Source-Projekt von Tobias Schnorpfeil – ohne Firma, Werbung oder Datensammlung dahinter. Der vollständige Quellcode steht unter MIT-Lizenz auf GitHub: Sie können ihn prüfen, selbst bauen oder selbst betreiben.

+
+
+
+ + +
+
-
-
+ + +
+

+ durchgestrichen = entfernt + grün = neu + blauer Rand = Absatz geändert + J / K = nächste / vorherige Änderung + Pfeile unten = nächste / vorherige Änderung +

+ +
-
-
+
+

Doc Diff

+
-
1 · Vorher (Alter Stand) hier ablegen (PDF / DOCX / TXT)
+
Alte Fassung hier ablegen
@@ -3324,22 +3855,141 @@ -
+
-
2 · Nachher (Neuer Stand) hier ablegen (PDF / DOCX / TXT)
+
Neue Fassung hier ablegen
-
+
- -
+ +
+
+
+

Sehen Sie Wort für Wort, was sich zwischen zwei Fassungen geändert hat.

+

Für Verträge, Ausschreibungen und andere Dokumente. Ihre Dateien verlassen dabei nie Ihren Computer.

+
    +
  • Kostenlos & Open Source (MIT)
  • +
  • Keine Uploads – alles läuft lokal im Browser
  • +
  • Keine Werbung, kein Tracking, keine Cookies
  • +
+ +
+
+ + +
+
+ + +
+
+

Sie können auch beide Dateien auf einmal hierher ziehen – die erste gilt als alte Fassung.

+ +
+ + +
+
+ +
+

So funktioniert’s

+
    +
  1. Zwei Fassungen wählen Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
  2. +
  3. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Die Liste rechts führt von Änderung zu Änderung.
  4. +
  5. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit markierten Änderungen, als PDF oder als Bericht speichern.
  6. +
+
+ +
+

Was es kann – und was nicht

+
+
+

Kann

+
    +
  • PDFs mit Textebene, Word-Dateien (.docx) und reinen Text vergleichen
  • +
  • Änderungen Wort für Wort zeigen – auch geänderte Zahlen, Daten und Beträge
  • +
  • Seiten wie im Original anzeigen; wiederkehrende Kopf- und Fußzeilen werden ausgeblendet
  • +
  • Ohne Internet arbeiten – als einzelne HTML-Datei
  • +
+
+
+

Kann nicht

+
    +
  • Gescannte PDFs lesen – es gibt keine Texterkennung (OCR)
  • +
  • Alte Word-Dateien (.doc) öffnen – bitte vorher als .docx speichern
  • +
  • Formatierung vergleichen – Fett, Kursiv, Tabellenlayout und Bilder werden ignoriert, verglichen wird nur der Text
  • +
  • Verschobene Absätze erkennen – sie erscheinen als entfernt und an anderer Stelle neu
  • +
+
+
+
+ +
+

Häufige Fragen

+
+ Wie kann ich prüfen, dass nichts hochgeladen wird? +
+

Die Seite enthält eine Sicherheitsregel (Content-Security-Policy), mit der Ihr Browser jede Netzwerkverbindung aus der Seite heraus sperrt. Ihre Dateien werden nur im Arbeitsspeicher dieses Tabs gelesen und beim Schließen verworfen.

+

So prüfen Sie es selbst: Öffnen Sie die Entwicklerwerkzeuge Ihres Browsers (F12), wählen Sie den Reiter „Netzwerk“ und laden Sie dann ein Dokument – es erscheint keine Anfrage. Oder laden Sie die Offline-Datei herunter, trennen Sie die Internetverbindung und öffnen Sie sie: Alles funktioniert weiter.

+
+
+
+ Kann ich Doc Diff offline nutzen? +
+

Ja. Laden Sie DocDiff.html herunter (eine einzelne Datei, etwa 2,5 MB) und öffnen Sie sie per Doppelklick im Browser – ohne Installation und ohne Internet.

+
+
+
+ Wer steckt dahinter? Wo ist der Quellcode? +
+

Doc Diff ist ein privates Open-Source-Projekt von Tobias Schnorpfeil – ohne Firma, Werbung oder Datensammlung dahinter. Der vollständige Quellcode steht unter MIT-Lizenz auf GitHub: Sie können ihn prüfen, selbst bauen oder selbst betreiben.

+
+
+
+ + +
+
-
-
+ + +
+

+ durchgestrichen = entfernt + grün = neu + blauer Rand = Absatz geändert + J / K = nächste / vorherige Änderung + Pfeile unten = nächste / vorherige Änderung +

+ +
-
-
+
+

Doc Diff

+
-
1 · Vorher (Alter Stand) hier ablegen (PDF / DOCX / TXT)
+
Alte Fassung hier ablegen
@@ -294,22 +314,141 @@ -
+
-
2 · Nachher (Neuer Stand) hier ablegen (PDF / DOCX / TXT)
+
Neue Fassung hier ablegen
-
+
- -
+ +
+
+
+

Sehen Sie Wort für Wort, was sich zwischen zwei Fassungen geändert hat.

+

Für Verträge, Ausschreibungen und andere Dokumente. Ihre Dateien verlassen dabei nie Ihren Computer.

+
    +
  • Kostenlos & Open Source (MIT)
  • +
  • Keine Uploads – alles läuft lokal im Browser
  • +
  • Keine Werbung, kein Tracking, keine Cookies
  • +
+ +
+
+ + +
+
+ + +
+
+

Sie können auch beide Dateien auf einmal hierher ziehen – die erste gilt als alte Fassung.

+ +
+ + +
+
+ +
+

So funktioniert’s

+
    +
  1. Zwei Fassungen wählen Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
  2. +
  3. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Die Liste rechts führt von Änderung zu Änderung.
  4. +
  5. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit markierten Änderungen, als PDF oder als Bericht speichern.
  6. +
+
+ +
+

Was es kann – und was nicht

+
+
+

Kann

+
    +
  • PDFs mit Textebene, Word-Dateien (.docx) und reinen Text vergleichen
  • +
  • Änderungen Wort für Wort zeigen – auch geänderte Zahlen, Daten und Beträge
  • +
  • Seiten wie im Original anzeigen; wiederkehrende Kopf- und Fußzeilen werden ausgeblendet
  • +
  • Ohne Internet arbeiten – als einzelne HTML-Datei
  • +
+
+
+

Kann nicht

+
    +
  • Gescannte PDFs lesen – es gibt keine Texterkennung (OCR)
  • +
  • Alte Word-Dateien (.doc) öffnen – bitte vorher als .docx speichern
  • +
  • Formatierung vergleichen – Fett, Kursiv, Tabellenlayout und Bilder werden ignoriert, verglichen wird nur der Text
  • +
  • Verschobene Absätze erkennen – sie erscheinen als entfernt und an anderer Stelle neu
  • +
+
+
+
+ +
+

Häufige Fragen

+
+ Wie kann ich prüfen, dass nichts hochgeladen wird? +
+

Die Seite enthält eine Sicherheitsregel (Content-Security-Policy), mit der Ihr Browser jede Netzwerkverbindung aus der Seite heraus sperrt. Ihre Dateien werden nur im Arbeitsspeicher dieses Tabs gelesen und beim Schließen verworfen.

+

So prüfen Sie es selbst: Öffnen Sie die Entwicklerwerkzeuge Ihres Browsers (F12), wählen Sie den Reiter „Netzwerk“ und laden Sie dann ein Dokument – es erscheint keine Anfrage. Oder laden Sie die Offline-Datei herunter, trennen Sie die Internetverbindung und öffnen Sie sie: Alles funktioniert weiter.

+
+
+
+ Kann ich Doc Diff offline nutzen? +
+

Ja. Laden Sie DocDiff.html herunter (eine einzelne Datei, etwa 2,5 MB) und öffnen Sie sie per Doppelklick im Browser – ohne Installation und ohne Internet.

+
+
+
+ Wer steckt dahinter? Wo ist der Quellcode? +
+

Doc Diff ist ein privates Open-Source-Projekt von Tobias Schnorpfeil – ohne Firma, Werbung oder Datensammlung dahinter. Der vollständige Quellcode steht unter MIT-Lizenz auf GitHub: Sie können ihn prüfen, selbst bauen oder selbst betreiben.

+
+
+
+ + +
+
-
-