Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Enhancements

- Add a read-only code diff to the revisions screen ([#80314](https://github.com/WordPress/gutenberg/pull/80314)).
- In-editor revisions: Highlight changed words in the code diff ([#81273](https://github.com/WordPress/gutenberg/pull/81273)).

### New Features

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { diffLines } from 'diff';
import { diffLines, diffWordsWithSpace } from 'diff';
import { Spinner } from '@wordpress/components';
import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
Expand All @@ -11,6 +11,12 @@ import { unlock } from '../../lock-unlock';

const MAX_DIFF_EDIT_LENGTH = 1000;
const DIFF_TIMEOUT = 100;
// Skip intra-line pairing when a block needs more than 2,500 line
// comparisons (50 × 50). Pairing is O(n*m).
const MAX_PAIRING_COMPARISONS = 2500;
// Match the word-diff cutoff used by
// WP_Text_Diff_Renderer_Table::$_diff_threshold in WordPress core.
const INTRA_LINE_DIFF_THRESHOLD = 0.6;

/**
* Diff parts often end in a newline. Remove the trailing empty item so it is
Expand All @@ -27,9 +33,223 @@ function splitLines( value ) {
return lines;
}

/**
* Counts how often each character appears in a line.
*
* @param {string} line Source line.
* @return {Map<string, number>} Character counts keyed by character.
*/
function getCharFrequency( line ) {
const frequency = new Map();
for ( const char of line ) {
frequency.set( char, ( frequency.get( char ) ?? 0 ) + 1 );
}
return frequency;
}

/**
* Calculates the normalized distance between two character-frequency maps.
* This follows WP_Text_Diff_Renderer_Table::compute_string_distance() in core.
*
* @param {Map<string, number>} removedFrequency Removed line frequencies.
* @param {Map<string, number>} addedFrequency Added line frequencies.
* @param {number} removedLength Removed line length.
* @return {number} Distance per removed-line character.
*/
function getStringDistance( removedFrequency, addedFrequency, removedLength ) {
let difference = 0;
for ( const [ char, count ] of removedFrequency ) {
difference += Math.abs( count - ( addedFrequency.get( char ) ?? 0 ) );
}
for ( const [ char, count ] of addedFrequency ) {
if ( ! removedFrequency.has( char ) ) {
difference += count;
}
}
return difference / removedLength;
}

/**
* Greedily matches removed and added lines by string distance. This follows
* WP_Text_Diff_Renderer_Table::interleave_changed_lines() in core. The matches
* control word diffing without changing row order.
*
* @param {string[]} removedLines Removed lines.
* @param {string[]} addedLines Added lines.
* @return {Array<[number, number]>} Matched [removed, added] index pairs.
*/
function pairChangedLines( removedLines, addedLines ) {
if ( removedLines.length * addedLines.length > MAX_PAIRING_COMPARISONS ) {
return [];
}

// Skip empty lines: they have no words to highlight and would cause a
// division by zero.
const removedCandidates = [];
removedLines.forEach( ( line, index ) => {
if ( line.length ) {
removedCandidates.push( {
index,
frequency: getCharFrequency( line ),
} );
}
} );
const addedCandidates = [];
addedLines.forEach( ( line, index ) => {
if ( line.length ) {
addedCandidates.push( {
index,
frequency: getCharFrequency( line ),
} );
}
} );

const matches = [];
for ( const removed of removedCandidates ) {
for ( const added of addedCandidates ) {
matches.push( {
removedIndex: removed.index,
addedIndex: added.index,
distance: getStringDistance(
removed.frequency,
added.frequency,
removedLines[ removed.index ].length
),
} );
}
}
matches.sort(
( a, b ) =>
a.distance - b.distance ||
a.removedIndex - b.removedIndex ||
a.addedIndex - b.addedIndex
);

const usedRemoved = new Set();
const usedAdded = new Set();
const pairs = [];
for ( const { removedIndex, addedIndex } of matches ) {
if ( usedRemoved.has( removedIndex ) || usedAdded.has( addedIndex ) ) {
continue;
}
usedRemoved.add( removedIndex );
usedAdded.add( addedIndex );
pairs.push( [ removedIndex, addedIndex ] );
}
return pairs;
}

/**
* Builds word-level segments for a matched pair. Returns null when the lines
* are identical, too different, or take too long to compare.
*
* @param {string} removedLine Removed line.
* @param {string} addedLine Added line.
* @param {number} timeout Milliseconds left for this word diff.
* @return {?{removedSegments: Array<Object>, addedSegments: Array<Object>}} Segments per side.
*/
function getLineSegments( removedLine, addedLine, timeout ) {
const wordDiff = diffWordsWithSpace( removedLine, addedLine, { timeout } );
if ( ! wordDiff ) {
return null;
}

let changedChars = 0;
let commonChars = 0;
for ( const part of wordDiff ) {
if ( part.added || part.removed ) {
changedChars += part.value.length;
} else {
commonChars += part.value.length;
}
}
if ( changedChars === 0 ) {
return null;
}
// Shared markup counts as common content. Since this view diffs raw markup
// without normalizing whitespace, the cutoff is looser than core's prose
// diff.
if (
changedChars / ( 2 * commonChars + changedChars ) >
INTRA_LINE_DIFF_THRESHOLD
) {
return null;
}

return {
removedSegments: wordDiff
.filter( ( part ) => ! part.added )
.map( ( part ) =>
part.removed
? { value: part.value, removed: true }
: { value: part.value }
),
addedSegments: wordDiff
.filter( ( part ) => ! part.removed )
.map( ( part ) =>
part.added
? { value: part.value, added: true }
: { value: part.value }
),
};
}

/**
* Pairs lines and builds word-level segments for each changed block. A changed
* block is a removed part followed by an added part.
*
* @param {Array<Object>} parts Line-diff parts.
* @return {Map<number, Map<number, Array<Object>>>} Segments keyed by part and line index.
*/
function getIntraLineSegments( parts ) {
const segmentsByPart = new Map();
// Share one timeout across the pass so it cannot reset for every line pair.
const deadline = Date.now() + DIFF_TIMEOUT;

for ( let i = 0; i < parts.length - 1 && Date.now() < deadline; i++ ) {
if ( ! parts[ i ].removed || ! parts[ i + 1 ].added ) {
continue;
}
const removedLines = splitLines( parts[ i ].value );
const addedLines = splitLines( parts[ i + 1 ].value );
for ( const [ removedIndex, addedIndex ] of pairChangedLines(
removedLines,
addedLines
) ) {
const remaining = deadline - Date.now();
if ( remaining <= 0 ) {
break;
}
const segments = getLineSegments(
removedLines[ removedIndex ],
addedLines[ addedIndex ],
remaining
);
if ( ! segments ) {
continue;
}
if ( ! segmentsByPart.has( i ) ) {
segmentsByPart.set( i, new Map() );
}
if ( ! segmentsByPart.has( i + 1 ) ) {
segmentsByPart.set( i + 1, new Map() );
}
segmentsByPart
.get( i )
.set( removedIndex, segments.removedSegments );
segmentsByPart
.get( i + 1 )
.set( addedIndex, segments.addedSegments );
}
// The added part cannot start another block.
i++;
}
return segmentsByPart;
}

/**
* Creates the rows shown in the code diff and adds line numbers for both
* revisions.
* revisions. Closely matched changed lines also include word-level segments.
*
* @param {string} previousContent Previous revision content.
* @param {string} currentContent Selected revision content.
Expand All @@ -53,25 +273,32 @@ export function getCodeDiffRows( previousContent, currentContent, showDiff ) {
];
}
}
const segmentsByPart = showDiff ? getIntraLineSegments( parts ) : new Map();

let previousLineNumber = 1;
let currentLineNumber = 1;

return parts.flatMap( ( part ) => {
return parts.flatMap( ( part, partIndex ) => {
let status = 'unchanged';
if ( part.added ) {
status = 'added';
} else if ( part.removed ) {
status = 'removed';
}

return splitLines( part.value ).map( ( value ) => {
return splitLines( part.value ).map( ( value, lineIndex ) => {
const row = {
value,
status,
previousLineNumber: null,
currentLineNumber: null,
};

const segments = segmentsByPart.get( partIndex )?.get( lineIndex );
if ( segments ) {
row.segments = segments;
}

if ( status !== 'added' && showDiff ) {
row.previousLineNumber = previousLineNumber++;
}
Expand Down Expand Up @@ -240,6 +467,28 @@ export function RevisionsCodeDiff( {
statusLabel = __( 'Removed' );
}

// Keep word-level highlights visual because the row
// already announces the change.
const code = row.segments
? row.segments.map(
( segment, segmentIndex ) =>
segment.added || segment.removed ? (
<span
key={ segmentIndex }
className={ `editor-revisions-code-diff__segment is-${
segment.added
? 'added'
: 'removed'
}` }
>
{ segment.value }
</span>
) : (
segment.value
)
)
: row.value;

return (
<tr
key={ index }
Expand All @@ -264,7 +513,7 @@ export function RevisionsCodeDiff( {
</td>
) }
<td className="editor-revisions-code-diff__code">
<code>{ row.value }</code>
<code>{ code }</code>
</td>
</tr>
);
Expand Down
23 changes: 23 additions & 0 deletions packages/editor/src/components/post-revisions-preview/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ $revision-code-diff-marker-width: 3ch;
}
}

// Use a stronger tint for word-level changes than for the surrounding line.
.editor-revisions-code-diff__segment {
&.is-added {
background: rgba($revision-diff-added-color, 0.3);
}

&.is-removed {
background: rgba($revision-diff-removed-color, 0.3);
}

// Forced colors replace both background tints, so use text decoration to
// keep word-level changes visible.
@media (forced-colors: active) {
&.is-added {
text-decoration: underline;
}

&.is-removed {
text-decoration: line-through;
}
}
}

.editor-revisions-code-diff__line-number,
.editor-revisions-code-diff__marker {
position: sticky;
Expand Down
Loading
Loading