From c606f6b3fb090ecc71c0a05e7d7b4d6ede5b1b6e Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:25:18 +0200 Subject: [PATCH 1/3] Highlight word-level changes in the revisions code diff --- .../revisions-code-diff.js | 252 +++++++++++++++++- .../post-revisions-preview/style.scss | 11 + .../test/revisions-code-diff.js | 127 ++++++++- 3 files changed, 383 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js b/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js index 280d1a7e78cb1a..42424aa56afe1c 100644 --- a/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js +++ b/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js @@ -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'; @@ -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 @@ -27,9 +33,216 @@ function splitLines( value ) { return lines; } +/** + * Counts how often each character appears in a line. + * + * @param {string} line Source line. + * @return {Map} 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} removedFrequency Removed line frequencies. + * @param {Map} 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. + * @return {?{removedSegments: Array, addedSegments: Array}} Segments per side. + */ +function getLineSegments( removedLine, addedLine ) { + const wordDiff = diffWordsWithSpace( removedLine, addedLine, { + timeout: DIFF_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} parts Line-diff parts. + * @return {Map>>} Segments keyed by part and line index. + */ +function getIntraLineSegments( parts ) { + const segmentsByPart = new Map(); + for ( let i = 0; i < parts.length - 1; 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 segments = getLineSegments( + removedLines[ removedIndex ], + addedLines[ addedIndex ] + ); + 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. @@ -53,10 +266,12 @@ 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'; @@ -64,7 +279,7 @@ export function getCodeDiffRows( previousContent, currentContent, showDiff ) { status = 'removed'; } - return splitLines( part.value ).map( ( value ) => { + return splitLines( part.value ).map( ( value, lineIndex ) => { const row = { value, status, @@ -72,6 +287,11 @@ export function getCodeDiffRows( previousContent, currentContent, showDiff ) { currentLineNumber: null, }; + const segments = segmentsByPart.get( partIndex )?.get( lineIndex ); + if ( segments ) { + row.segments = segments; + } + if ( status !== 'added' && showDiff ) { row.previousLineNumber = previousLineNumber++; } @@ -240,6 +460,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 ? ( + + { segment.value } + + ) : ( + segment.value + ) + ) + : row.value; + return ( ) } - { row.value } + { code } ); diff --git a/packages/editor/src/components/post-revisions-preview/style.scss b/packages/editor/src/components/post-revisions-preview/style.scss index 6f2001e1faeb39..057ca938f22de8 100644 --- a/packages/editor/src/components/post-revisions-preview/style.scss +++ b/packages/editor/src/components/post-revisions-preview/style.scss @@ -86,6 +86,17 @@ $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); + } +} + .editor-revisions-code-diff__line-number, .editor-revisions-code-diff__marker { position: sticky; diff --git a/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js b/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js index e93277a0a7da33..e81314d891ab1c 100644 --- a/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js +++ b/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js @@ -127,6 +127,100 @@ describe( 'getCodeDiffRows', () => { currentLineNumber: 501, } ); } ); + + it( 'adds word-level segments to similar changed lines', () => { + const rows = getCodeDiffRows( + '

Hello world

\n', + '

Hello there

\n', + true + ); + + expect( rows ).toHaveLength( 2 ); + expect( rows[ 0 ].segments ).toEqual( [ + { value: '

Hello ' }, + { value: 'world', removed: true }, + { value: '

' }, + ] ); + expect( rows[ 1 ].segments ).toEqual( [ + { value: '

Hello ' }, + { value: 'there', added: true }, + { value: '

' }, + ] ); + // Each side's segments reconstruct the full line. + for ( const row of rows ) { + expect( + row.segments.map( ( segment ) => segment.value ).join( '' ) + ).toBe( row.value ); + } + } ); + + it( 'keeps line-level highlighting for dissimilar lines', () => { + const rows = getCodeDiffRows( 'abcdefgh\n', 'stuvwxyz\n', true ); + + expect( rows ).toHaveLength( 2 ); + expect( rows[ 0 ] ).not.toHaveProperty( 'segments' ); + expect( rows[ 1 ] ).not.toHaveProperty( 'segments' ); + } ); + + it( 'pairs changed lines by similarity rather than position', () => { + const rows = getCodeDiffRows( + '

The quick brown fox

\n

Completely unrelated words

\n', + '

Some other sentence entirely

\n

The quick brown foxes

\n', + true + ); + + expect( rows ).toHaveLength( 4 ); + // The first removed line matches the second added line. + expect( rows[ 0 ].segments ).toEqual( [ + { value: '

The quick brown ' }, + { value: 'fox', removed: true }, + { value: '

' }, + ] ); + expect( rows[ 3 ].segments ).toEqual( [ + { value: '

The quick brown ' }, + { value: 'foxes', added: true }, + { value: '

' }, + ] ); + // The remaining lines are too different for word highlights. + expect( rows[ 1 ] ).not.toHaveProperty( 'segments' ); + expect( rows[ 2 ] ).not.toHaveProperty( 'segments' ); + } ); + + it( 'skips word-level pairing when a changed block is too large', () => { + const previousLines = Array.from( + { length: 51 }, + ( _, index ) => `Before line ${ index }` + ); + const currentLines = Array.from( + { length: 51 }, + ( _, index ) => `After line ${ index }` + ); + const rows = getCodeDiffRows( + `Start\n${ previousLines.join( '\n' ) }\nEnd\n`, + `Start\n${ currentLines.join( '\n' ) }\nEnd\n`, + true + ); + + expect( rows ).toHaveLength( 104 ); + expect( rows.every( ( row ) => ! ( 'segments' in row ) ) ).toBe( true ); + } ); + + it( 'skips blank lines when pairing changed blocks', () => { + const rows = getCodeDiffRows( + 'Start\nAlpha beta gamma\n\nDelta epsilon zeta\nEnd\n', + 'Start\nAlpha beta gamma changed\nDelta epsilon zeta changed\nEnd\n', + true + ); + + const blankRow = rows.find( ( row ) => row.value === '' ); + expect( blankRow.status ).toBe( 'removed' ); + expect( blankRow ).not.toHaveProperty( 'segments' ); + // The non-blank lines around it still pair up. + expect( rows[ 1 ] ).toHaveProperty( 'segments' ); + expect( rows[ 3 ] ).toHaveProperty( 'segments' ); + expect( rows[ 4 ] ).toHaveProperty( 'segments' ); + expect( rows[ 5 ] ).toHaveProperty( 'segments' ); + } ); } ); describe( 'getCodeDiffDisplayState', () => { @@ -183,11 +277,13 @@ describe( 'RevisionsCodeDiff', () => { expect( screen.getByRole( 'region', { name: 'Code changes' } ) ).toBeVisible(); + // jsdom inserts spaces between accessible names from adjacent inline + // nodes; browsers do not. expect( - screen.getByRole( 'row', { name: /Removed.*

Before<\/p>/ } ) + screen.getByRole( 'row', { name: /Removed.*

\s*Before\s*<\/p>/ } ) ).toHaveClass( 'is-removed' ); expect( - screen.getByRole( 'row', { name: /Added.*

After<\/p>/ } ) + screen.getByRole( 'row', { name: /Added.*

\s*After\s*<\/p>/ } ) ).toHaveClass( 'is-added' ); } ); @@ -228,6 +324,33 @@ describe( 'RevisionsCodeDiff', () => { ).toHaveClass( 'is-added' ); } ); + it( 'highlights changed words without announcing them separately', () => { + renderCodeDiff( { + previousContent: '\n

Hello world

', + currentContent: '\n

Hello there

', + } ); + + expect( + screen.getByText( 'world', { + selector: '.editor-revisions-code-diff__segment.is-removed', + } ) + ).toBeVisible(); + expect( + screen.getByText( 'there', { + selector: '.editor-revisions-code-diff__segment.is-added', + } ) + ).toBeVisible(); + // The row announces the change once. Highlight spans do not add + // insertion or deletion roles. + expect( + screen.getByRole( 'row', { + name: /Removed.*

Hello\s+world\s*<\/p>/, + } ) + ).toHaveClass( 'is-removed' ); + expect( screen.queryAllByRole( 'deletion' ) ).toHaveLength( 0 ); + expect( screen.queryAllByRole( 'insertion' ) ).toHaveLength( 0 ); + } ); + it( 'preserves blank source lines without inserting text', () => { renderCodeDiff( { currentContent: 'First\n\nLast', From 1ca80d4a91b9f84b61151728283d9aa71ae39647 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:12:31 +0200 Subject: [PATCH 2/3] Bound the revisions word diff by total time and support forced colors --- .../revisions-code-diff.js | 19 +++++++++++------ .../post-revisions-preview/style.scss | 12 +++++++++++ .../test/revisions-code-diff.js | 21 +++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js b/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js index 42424aa56afe1c..a48eb7157ff347 100644 --- a/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js +++ b/packages/editor/src/components/post-revisions-preview/revisions-code-diff.js @@ -145,12 +145,11 @@ function pairChangedLines( removedLines, addedLines ) { * * @param {string} removedLine Removed line. * @param {string} addedLine Added line. + * @param {number} timeout Milliseconds left for this word diff. * @return {?{removedSegments: Array, addedSegments: Array}} Segments per side. */ -function getLineSegments( removedLine, addedLine ) { - const wordDiff = diffWordsWithSpace( removedLine, addedLine, { - timeout: DIFF_TIMEOUT, - } ); +function getLineSegments( removedLine, addedLine, timeout ) { + const wordDiff = diffWordsWithSpace( removedLine, addedLine, { timeout } ); if ( ! wordDiff ) { return null; } @@ -204,7 +203,10 @@ function getLineSegments( removedLine, addedLine ) { */ function getIntraLineSegments( parts ) { const segmentsByPart = new Map(); - for ( let i = 0; i < parts.length - 1; i++ ) { + // 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; } @@ -214,9 +216,14 @@ function getIntraLineSegments( parts ) { removedLines, addedLines ) ) { + const remaining = deadline - Date.now(); + if ( remaining <= 0 ) { + break; + } const segments = getLineSegments( removedLines[ removedIndex ], - addedLines[ addedIndex ] + addedLines[ addedIndex ], + remaining ); if ( ! segments ) { continue; diff --git a/packages/editor/src/components/post-revisions-preview/style.scss b/packages/editor/src/components/post-revisions-preview/style.scss index 057ca938f22de8..2dc8f4f2107efc 100644 --- a/packages/editor/src/components/post-revisions-preview/style.scss +++ b/packages/editor/src/components/post-revisions-preview/style.scss @@ -95,6 +95,18 @@ $revision-code-diff-marker-width: 3ch; &.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, diff --git a/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js b/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js index e81314d891ab1c..63f94b1e515ea9 100644 --- a/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js +++ b/packages/editor/src/components/post-revisions-preview/test/revisions-code-diff.js @@ -205,6 +205,27 @@ describe( 'getCodeDiffRows', () => { expect( rows.every( ( row ) => ! ( 'segments' in row ) ) ).toBe( true ); } ); + it( 'shares one time budget across word-level diffs', () => { + // These unrelated lines force word diffing to time out. All word diffs + // share one budget. + const makeContent = ( seed ) => + Array.from( { length: 50 }, ( _, line ) => + [ ...Array( 1000 ).keys() ] + .map( ( token ) => `w${ seed }${ line }_${ token }` ) + .join( ' ' ) + ).join( '\n' ); + const previousContent = `Start\n${ makeContent( 'a' ) }\nEnd\n`; + const currentContent = `Start\n${ makeContent( 'b' ) }\nEnd\n`; + + const start = Date.now(); + const rows = getCodeDiffRows( previousContent, currentContent, true ); + const elapsed = Date.now() - start; + + expect( rows ).toHaveLength( 102 ); + expect( rows.every( ( row ) => ! ( 'segments' in row ) ) ).toBe( true ); + expect( elapsed ).toBeLessThan( 1000 ); + } ); + it( 'skips blank lines when pairing changed blocks', () => { const rows = getCodeDiffRows( 'Start\nAlpha beta gamma\n\nDelta epsilon zeta\nEnd\n', From f64d25156a5f69a36489d94a5a53f477c7d80db8 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:26:44 +0200 Subject: [PATCH 3/3] Add editor changelog entry --- packages/editor/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index a18d6ada046cfe..01d9c1ce8cf64d 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -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