OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

version_compare.php

17 KB
<?php
/**
 * Versionsvergleich
 * Modernes Full-Width Design mit Hero Header
 */

// Sichere Defaults
$topic = $topic ?? ['id' => 0, 'name' => 'Unbekannt'];
$topic_ancestors = $topic_ancestors ?? [];
$page = $page ?? ['id' => 0, 'title' => 'Unbekannt'];
$version1 = $version1 ?? ['id' => 0, 'version_number' => 0, 'content_html' => '', 'created_at' => '', 'created_by_name' => 'Unbekannt'];
$version2 = $version2 ?? ['id' => 0, 'version_number' => 0, 'content_html' => '', 'created_at' => '', 'created_by_name' => 'Unbekannt'];

$this->layout('layouts/main', [
    'page_title' => 'Versionsvergleich: ' . $page['title'],
    'breadcrumbs' => [],
    'full_width' => true
]);
?>

<div class="content-header">
    <div>
        <h1><?= $this->e($page['title']) ?> <small style="font-size:0.75rem;color:var(--gray-500);">v<?= $version1['version_number'] ?> vs. v<?= $version2['version_number'] ?></small></h1>
        <ol class="breadcrumb">
            <li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
            <li class="breadcrumb-item"><a href="/topics"><?= $this->te('Themengebiete') ?></a></li>
            <?php foreach ($topic_ancestors as $ancestor): ?>
                <li class="breadcrumb-item"><a href="/topics/<?= $ancestor['id'] ?>"><?= $this->e($ancestor['name']) ?></a></li>
            <?php endforeach; ?>
            <li class="breadcrumb-item"><a href="/topics/<?= $topic['id'] ?>"><?= $this->e($topic['name']) ?></a></li>
            <li class="breadcrumb-item"><a href="/pages/<?= $page['id'] ?>"><?= $this->e($page['title']) ?></a></li>
            <li class="breadcrumb-item"><a href="/pages/<?= $page['id'] ?>/versions"><?= $this->te('Versionen') ?></a></li>
            <li class="breadcrumb-item active"><?= $this->te('Vergleich') ?></li>
        </ol>
    </div>
    <div class="content-header-actions">
        <a href="/pages/<?= $page['id'] ?>/versions" class="btn btn-default btn-sm"><span aria-hidden="true">&#128218;</span> <?= $this->te('Alle Versionen') ?></a>
        <a href="/pages/<?= $page['id'] ?>" class="btn btn-default btn-sm"><span aria-hidden="true">&#128196;</span> <?= $this->te('Aktuelle Seite') ?></a>
    </div>
</div>

<div class="compare-content">
    <!-- Version Headers -->
    <div class="compare-headers">
        <div class="version-header version-old">
            <div class="version-badge-row">
                <span class="status-pill status-pill--neutral">Version <?= $version1['version_number'] ?></span>
            </div>
            <div class="version-meta">
                <span><span aria-hidden="true">&#128197;</span> <?= date('d.m.Y H:i', strtotime($version1['created_at'])) ?></span>
                <span><span aria-hidden="true">&#128100;</span> <?= $this->e($version1['created_by_name'] ?? 'Unbekannt') ?></span>
            </div>
            <?php if (!empty($version1['change_reason'])): ?>
                <div class="version-reason"><?= $this->e($version1['change_reason']) ?></div>
            <?php endif; ?>
            <a href="/pages/<?= $page['id'] ?>/versions/<?= $version1['id'] ?>" class="version-link">
                <?= $this->te('Version anzeigen &rarr;') ?>
            </a>
        </div>
        <div class="version-header version-new">
            <div class="version-badge-row">
                <span class="status-pill status-pill--success">Version <?= $version2['version_number'] ?></span>
            </div>
            <div class="version-meta">
                <span><span aria-hidden="true">&#128197;</span> <?= date('d.m.Y H:i', strtotime($version2['created_at'])) ?></span>
                <span><span aria-hidden="true">&#128100;</span> <?= $this->e($version2['created_by_name'] ?? 'Unbekannt') ?></span>
            </div>
            <?php if (!empty($version2['change_reason'])): ?>
                <div class="version-reason"><?= $this->e($version2['change_reason']) ?></div>
            <?php endif; ?>
            <a href="/pages/<?= $page['id'] ?>/versions/<?= $version2['id'] ?>" class="version-link">
                <?= $this->te('Version anzeigen &rarr;') ?>
            </a>
        </div>
    </div>

    <!-- Side by Side Comparison -->
    <div class="compare-columns">
        <div class="compare-column column-old">
            <div class="column-header">
                <span class="column-label"><?= $this->te('Aelter') ?></span>
                <span class="column-version">v<?= $version1['version_number'] ?></span>
            </div>
            <div class="column-content wysiwyg-content">
                <?= $this->fixRelativeUrls($version1['content_html']) ?>
            </div>
        </div>
        <div class="compare-column column-new">
            <div class="column-header">
                <span class="column-label"><?= $this->te('Neuer') ?></span>
                <span class="column-version">v<?= $version2['version_number'] ?></span>
            </div>
            <div class="column-content wysiwyg-content">
                <?= $this->fixRelativeUrls($version2['content_html']) ?>
            </div>
        </div>
    </div>

    <!-- Diff View -->
    <div class="diff-section">
        <div class="diff-header">
            <h2><span aria-hidden="true">&#128260;</span> <?= $this->te('&Auml;nderungen') ?></h2>
            <div class="diff-legend">
                <span class="legend-item"><span class="legend-color legend-added"></span> <?= $this->te('Hinzugef&uuml;gt') ?></span>
                <span class="legend-item"><span class="legend-color legend-removed"></span> <?= $this->te('Entfernt') ?></span>
            </div>
        </div>
        <div class="diff-content" id="diffOutput">
            <p class="diff-note"><?= $this->te('Diff wird berechnet...') ?></p>
        </div>
    </div>

    <!-- Navigation -->
    <div class="compare-nav">
        <a href="/pages/<?= $page['id'] ?>/versions" class="btn btn-default">
            <span aria-hidden="true">&#8592;</span> <?= $this->te('Zurueck zur Versionshistorie') ?>
        </a>
    </div>
</div>

<style>
/* Main Content */
.compare-content {
    padding: 2rem;
    background: var(--slate-50);
}

/* Version Headers */
.compare-headers {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1.5rem;
    margin-bottom: 1.5rem;
}

.version-header {
    background: var(--color-bg);
    border-radius: 0.75rem;
    padding: 1.25rem;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.version-header.version-old {
    border-left: 4px solid var(--red-500);
}

.version-header.version-new {
    border-left: 4px solid var(--green-500);
}

.version-badge-row {
    margin-bottom: 0.75rem;
}

.version-meta {
    display: flex;
    gap: 1.5rem;
    font-size: 0.85rem;
    color: var(--gray-500);
    margin-bottom: 0.5rem;
}

.version-reason {
    font-size: 0.9rem;
    color: var(--gray-600);
    font-style: italic;
    margin-bottom: 0.75rem;
}

.version-link {
    font-size: 0.85rem;
    color: var(--indigo-500);
    text-decoration: none;
}

.version-link:hover {
    text-decoration: underline;
}

/* Compare Columns */
.compare-columns {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1.5rem;
    margin-bottom: 1.5rem;
}

.compare-column {
    background: var(--color-bg);
    border-radius: 0.75rem;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
    overflow: hidden;
}

.column-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.75rem 1.25rem;
    border-bottom: 1px solid var(--gray-200);
}

.column-old .column-header {
    background: var(--red-50);
}

.column-new .column-header {
    background: var(--green-50);
}

.column-label {
    font-weight: 600;
    font-size: 0.85rem;
    text-transform: uppercase;
    letter-spacing: 0.025em;
}

.column-old .column-label {
    color: var(--red-600);
}

.column-new .column-label {
    color: var(--green-600);
}

.column-version {
    font-weight: 600;
    font-size: 0.85rem;
    color: var(--gray-500);
}

.column-content {
    padding: 1.5rem;
    max-height: 600px;
    overflow-y: auto;
}

.column-content.wysiwyg-content {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
    font-size: 0.95rem;
    line-height: 1.7;
    color: var(--gray-800);
}

.wysiwyg-content :not(code):not(pre):not(kbd):not(samp) {
    font-family: inherit !important;
}

.wysiwyg-content table { width: 100% !important; max-width: 100% !important; table-layout: fixed; }
.wysiwyg-content th, .wysiwyg-content td { word-wrap: break-word; overflow-wrap: break-word; }

.wysiwyg-content h1 { font-size: 1.5rem; margin: 1.5rem 0 0.75rem; }
.wysiwyg-content h2 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; border-bottom: 2px solid var(--gray-200); padding-bottom: 0.5rem; }
.wysiwyg-content h3 { font-size: 1.1rem; margin: 1rem 0 0.5rem; }
.wysiwyg-content p { margin: 0 0 0.75rem; }
.wysiwyg-content ul, .wysiwyg-content ol { margin: 0 0 0.75rem; padding-left: 1.25rem; }
.wysiwyg-content li { margin-bottom: 0.2rem; }
.wysiwyg-content table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; font-size: 0.85rem; }
.wysiwyg-content th, .wysiwyg-content td { border: 1px solid var(--gray-200); padding: 0.4rem 0.6rem; text-align: left; }
.wysiwyg-content th { background: var(--gray-50); font-weight: 600; }
.wysiwyg-content pre { background: var(--gray-800); color: var(--gray-50); padding: 0.75rem; border-radius: 0.375rem; overflow-x: auto; font-size: 0.8rem; }
.wysiwyg-content code { background: var(--gray-100); padding: 0.1rem 0.3rem; border-radius: 0.2rem; font-size: 0.85em; }
.wysiwyg-content pre code { background: transparent; padding: 0; }
.wysiwyg-content blockquote { border-left: 3px solid var(--blue-600); margin: 0.75rem 0; padding: 0.4rem 0.75rem; background: var(--blue-50); }
.wysiwyg-content img { max-width: 100%; height: auto; border-radius: 0.375rem; display: block; }

/* Navigation */
.compare-nav {
    display: flex;
    justify-content: center;
    padding-top: 1rem;
}

/* Diff Section */
.diff-section {
    background: var(--color-bg);
    border-radius: 0.75rem;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
    overflow: hidden;
    margin-bottom: 1.5rem;
}

.diff-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.75rem 1.25rem;
    background: var(--gray-50);
    border-bottom: 1px solid var(--gray-200);
}

.diff-header h2 {
    margin: 0;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--gray-700);
}

.diff-legend {
    display: flex;
    gap: 1rem;
    font-size: 0.8rem;
    color: var(--gray-500);
}

.legend-item {
    display: flex;
    align-items: center;
    gap: 0.375rem;
}

.legend-color {
    width: 14px;
    height: 14px;
    border-radius: 0.25rem;
}

.legend-added { background: var(--green-100); border: 1px solid var(--green-300); }
.legend-removed { background: var(--red-100); border: 1px solid var(--red-300); }

.diff-content {
    padding: 1.5rem;
    font-size: 0.95rem;
    line-height: 1.8;
    color: var(--gray-800);
    max-height: 600px;
    overflow-y: auto;
    white-space: pre-wrap;
    word-wrap: break-word;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.diff-added {
    background: var(--green-100);
    color: var(--green-800);
    padding: 0.05rem 0.2rem;
    border-radius: 0.15rem;
    text-decoration: none;
}

.diff-removed {
    background: var(--red-100);
    color: var(--red-800);
    padding: 0.05rem 0.2rem;
    border-radius: 0.15rem;
    text-decoration: line-through;
}

/* Statusmeldung im Diff-Bereich (Berechnung, keine Unterschiede, nur Formatierung) */
.diff-note {
    color: var(--gray-400);
    text-align: center;
    margin: 0;
}

/* Responsive */
@media (max-width: 900px) {
    .compare-headers,
    .compare-columns {
        grid-template-columns: 1fr;
    }

    .version-header.version-new {
        order: -1;
    }

    .compare-column.column-new {
        order: -1;
    }
}

@media (max-width: 768px) {
    .version-meta {
        flex-direction: column;
        gap: 0.25rem;
    }

    .column-content {
        padding: 1rem;
        max-height: 400px;
    }

    .diff-content {
        padding: 1rem;
        font-size: 0.85rem;
        max-height: 400px;
    }

    .diff-header {
        flex-direction: column;
        gap: 0.5rem;
        align-items: flex-start;
    }
}

@media (max-width: 480px) {
    .version-header {
        padding: 1rem;
    }

    .column-content {
        padding: 0.75rem;
    }

    .diff-content {
        padding: 0.75rem;
        font-size: 0.8rem;
    }
}
</style>

<script>
// Simple word-level diff
(function() {
    var oldEl = document.querySelector('.column-old .column-content');
    var newEl = document.querySelector('.column-new .column-content');
    var output = document.getElementById('diffOutput');
    if (!oldEl || !newEl || !output) return;

    var oldText = oldEl.innerText.replace(/\r\n/g, '\n');
    var newText = newEl.innerText.replace(/\r\n/g, '\n');

    var oldWords = oldText.split(/(\s+)/);
    var newWords = newText.split(/(\s+)/);

    // LCS-based diff
    var m = oldWords.length;
    var n = newWords.length;

    // For very large texts, use a simpler line-based approach
    if (m * n > 500000) {
        var oldLines = oldText.split('\n');
        var newLines = newText.split('\n');
        var lineResult = lineDiff(oldLines, newLines);
        renderDiff(lineResult.html, lineResult.changed);
        return;
    }

    // Build LCS table
    var dp = [];
    for (var i = 0; i <= m; i++) {
        dp[i] = new Uint16Array(n + 1);
    }
    for (var i = 1; i <= m; i++) {
        for (var j = 1; j <= n; j++) {
            if (oldWords[i-1] === newWords[j-1]) {
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
            }
        }
    }

    // Backtrack to build diff
    var result = [];
    var i = m, j = n;
    while (i > 0 || j > 0) {
        if (i > 0 && j > 0 && oldWords[i-1] === newWords[j-1]) {
            result.unshift({type: 'same', text: oldWords[i-1]});
            i--; j--;
        } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
            result.unshift({type: 'added', text: newWords[j-1]});
            j--;
        } else {
            result.unshift({type: 'removed', text: oldWords[i-1]});
            i--;
        }
    }

    // Render diff
    var html = '';
    var changed = false;
    for (var k = 0; k < result.length; k++) {
        var r = result[k];
        var escaped = escapeHtml(r.text);
        if (r.type === 'same') {
            html += escaped;
        } else if (r.type === 'added') {
            html += '<span class="diff-added">' + escaped + '</span>';
            changed = true;
        } else {
            html += '<span class="diff-removed">' + escaped + '</span>';
            changed = true;
        }
    }

    renderDiff(html, changed);

    /**
     * Zeigt den Diff an. Ohne Textunterschiede wird zusaetzlich das gerenderte
     * HTML beider Versionen verglichen, damit reine Formatierungs-, Tabellen-
     * oder Bildaenderungen nicht als "keine Unterschiede" erscheinen.
     */
    function renderDiff(diffHtml, hasTextChanges) {
        if (hasTextChanges) {
            output.innerHTML = diffHtml;
            return;
        }

        if (normalizeHtml(oldEl) !== normalizeHtml(newEl)) {
            output.innerHTML = '<p class="diff-note">Keine Textunterschiede &ndash; es wurden nur Formatierung, Tabellen oder Bilder ge&auml;ndert.</p>';
            return;
        }

        output.innerHTML = '<p class="diff-note">Keine Unterschiede gefunden.</p>';
    }

    // Whitespace zusammenfassen, damit reine Einrueckungen im Quelltext nicht als Aenderung zaehlen
    function normalizeHtml(el) {
        return el.innerHTML.replace(/\s+/g, ' ').trim();
    }

    function lineDiff(oldLines, newLines) {
        var html = '';
        var lineChanged = false;
        var ml = oldLines.length, nl = newLines.length;
        var d = [];
        for (var i = 0; i <= ml; i++) { d[i] = new Uint16Array(nl + 1); }
        for (var i = 1; i <= ml; i++) {
            for (var j = 1; j <= nl; j++) {
                d[i][j] = oldLines[i-1] === newLines[j-1] ? d[i-1][j-1]+1 : Math.max(d[i-1][j], d[i][j-1]);
            }
        }
        var res = [];
        var i = ml, j = nl;
        while (i > 0 || j > 0) {
            if (i > 0 && j > 0 && oldLines[i-1] === newLines[j-1]) {
                res.unshift({type:'same',text:oldLines[i-1]}); i--; j--;
            } else if (j > 0 && (i === 0 || d[i][j-1] >= d[i-1][j])) {
                res.unshift({type:'added',text:newLines[j-1]}); j--;
            } else {
                res.unshift({type:'removed',text:oldLines[i-1]}); i--;
            }
        }
        for (var k = 0; k < res.length; k++) {
            var r = res[k];
            var t = escapeHtml(r.text);
            if (r.type === 'same') {
                html += t + '\n';
            } else if (r.type === 'added') {
                html += '<span class="diff-added">' + t + '</span>\n';
                lineChanged = true;
            } else {
                html += '<span class="diff-removed">' + t + '</span>\n';
                lineChanged = true;
            }
        }
        return { html: html, changed: lineChanged };
    }

    function escapeHtml(str) {
        return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
    }
})();
</script>