diff --git a/extensions/markdown-language-features/media/markdown.css b/extensions/markdown-language-features/media/markdown.css index b468a73103d..85e49ca3b6f 100644 --- a/extensions/markdown-language-features/media/markdown.css +++ b/extensions/markdown-language-features/media/markdown.css @@ -134,7 +134,8 @@ body.showEditorSelection .code-line { z-index: 2; } -.diff-change-indicator-arrow:hover > .diff-change-indicator-tooltip { +.diff-change-indicator-arrow:hover > .diff-change-indicator-tooltip, +.diff-change-indicator-arrow > .diff-change-indicator-tooltip:hover { display: block; } @@ -179,12 +180,13 @@ body.showEditorSelection .code-line { cursor: pointer; } -.diff-modification-gutter:hover > .diff-change-indicator-tooltip { +.diff-modification-gutter:hover > .diff-change-indicator-tooltip, +.diff-modification-gutter > .diff-change-indicator-tooltip:hover { display: block; } .diff-modification-gutter > .diff-change-indicator-tooltip { - left: 8px; + left: 4px; top: 0; right: auto; width: max-content; @@ -201,14 +203,18 @@ body.showEditorSelection .code-line { background-color: var(--vscode-editor-background); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder, rgba(127, 127, 127, 0.35))); border-radius: 3px; - max-height: 400px; - overflow: auto; + max-height: min(400px, 80vh); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .diff-change-indicator-tooltip pre { margin: 0; font-size: 0.9em; + background: transparent; + border: 0; white-space: pre-wrap; word-break: break-word; color: var(--vscode-editor-foreground); @@ -217,32 +223,30 @@ body.showEditorSelection .code-line { .diff-tooltip-deleted { background-color: var(--vscode-diffEditor-removedTextBackground); border-left: 4px solid var(--vscode-diffEditorGutter-removedLineBackground, var(--vscode-diffEditor-removedTextBackground)); - padding: 4px 8px; + padding: 2px 6px; } .diff-tooltip-added { background-color: var(--vscode-diffEditor-insertedTextBackground); border-left: 4px solid var(--vscode-diffEditorGutter-insertedLineBackground, var(--vscode-diffEditor-insertedTextBackground)); - padding: 4px 8px; + padding: 2px 6px; } -.diff-tooltip-unchanged { - padding: 4px 8px; - border-left: 4px solid transparent; +.diff-tooltip-inner-deleted, +.diff-tooltip-inner-added { + box-decoration-break: clone; } -.diff-inline-deleted { +.diff-tooltip-inner-deleted { background-color: var(--vscode-diffEditor-removedTextBackground); text-decoration: line-through; text-decoration-color: var(--vscode-editor-foreground); } -.diff-inline-added { +.diff-tooltip-inner-added { background-color: var(--vscode-diffEditor-insertedTextBackground); } - - body.showEditorSelection :not(tr,ul,ol).code-active-line:before, body.showEditorSelection :not(tr,ul,ol).code-line:hover:before { content: ""; diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index 846651e8d39..dc2b28c054d 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -6,11 +6,11 @@ import { ActiveLineMarker } from './activeLineMarker'; import { onceDocumentLoaded } from './events'; import { createPosterForVsCode } from './messaging'; -import { getEditorLineNumberForPageOffset, getElementsForSourceLine, getLineElementForFragment, scrollToRevealSourceLine } from './scroll-sync'; +import { getEditorLineNumberForPageOffset, getElementsForSourceLine, getElementsForSourceLineRange, getLineElementForFragment, scrollToRevealSourceLine } from './scroll-sync'; import { SettingsManager, getData, getRawData } from './settings'; import throttle = require('lodash.throttle'); import morphdom from 'morphdom'; -import type { MarkdownPreviewLineChanges, ToWebviewMessage } from '../types/previewMessaging'; +import type { MarkdownPreviewChangeIndicator, MarkdownPreviewInnerChange, MarkdownPreviewLineChanges, ToWebviewMessage } from '../types/previewMessaging'; import { isOfScheme, Schemes } from '../src/util/schemes'; import { DiffScrollSyncManager } from './diffScrollSync'; @@ -384,62 +384,82 @@ function applyChangeIndicators(lineChanges: MarkdownPreviewLineChanges | undefin return; } - for (const indicator of lineChanges.changeIndicators) { - const { previous, next } = getElementsForSourceLine(indicator.modifiedLine, documentVersion); + for (const block of getRenderedChangeBlocks(lineChanges.changeIndicators)) { + if (block.indicator.type === 'deletion') { + const wrapper = createChangeIndicatorElement(block.indicator); + block.elements[0].parentElement?.insertBefore(wrapper, block.elements[0]); + continue; + } - if (indicator.type === 'deletion') { - // For pure deletions, the indicator should appear just before the line at modifiedLine. - // Use `next` if previous doesn't exactly match the target line, since the deletion - // point is between two elements. - const targetElement = (previous.line === indicator.modifiedLine) - ? (previous.codeElement || previous.element) - : (next?.codeElement || next?.element || previous.codeElement || previous.element); - if (!targetElement) { - continue; - } - - const wrapper = createChangeIndicatorElement(indicator); - targetElement.parentElement?.insertBefore(wrapper, targetElement); - } else { - // For modifications, mark each modified line and add a gutter bar - const seen = new Set(); - let isFirst = true; - for (let i = 0; i < indicator.modifiedLineCount; i++) { - const line = indicator.modifiedLine + i; - const { previous: p, next: n } = getElementsForSourceLine(line, documentVersion); - const lineElement = p.line >= 0 ? p : n; - const element = (lineElement?.codeElement || lineElement?.element) as HTMLElement | undefined; - if (element && !seen.has(element)) { - seen.add(element); - element.classList.add('code-line-diff-modified'); - addModificationGutterBar(element, isFirst ? indicator : undefined); - isFirst = false; - } - } + let isFirst = true; + for (const element of block.elements) { + element.classList.add('code-line-diff-modified'); + addModificationGutterBar(element, isFirst ? block.indicator : undefined); + isFirst = false; } } } -function createChangeIndicatorElement(indicator: { type: string; originalLineCount: number; originalContent: string; modifiedContent: string }): HTMLDivElement { +interface RenderedChangeBlock { + readonly indicator: MarkdownPreviewChangeIndicator; + readonly elements: readonly HTMLElement[]; +} + +function getRenderedChangeBlocks(indicators: readonly MarkdownPreviewChangeIndicator[]): RenderedChangeBlock[] { + const blocks: RenderedChangeBlock[] = []; + for (const indicator of indicators) { + const elements = indicator.type === 'deletion' + ? getDeletionChangeElements(indicator.modifiedLine) + : getModificationChangeElements(indicator.modifiedLine, indicator.modifiedLineCount); + if (elements.length) { + blocks.push({ indicator, elements }); + } + } + return blocks; +} + +function getDeletionChangeElements(modifiedLine: number): readonly HTMLElement[] { + const { previous, next } = getElementsForSourceLine(modifiedLine, documentVersion); + const targetElement = (previous.line === modifiedLine) + ? (previous.codeElement || previous.element) + : (next?.codeElement || next?.element || previous.codeElement || previous.element); + return targetElement ? [targetElement] : []; +} + +function getModificationChangeElements(modifiedLine: number, modifiedLineCount: number): readonly HTMLElement[] { + const elements: HTMLElement[] = []; + const seen = new Set(); + const lineElements = getElementsForSourceLineRange(modifiedLine, modifiedLine + modifiedLineCount, documentVersion); + for (const lineElement of lineElements) { + const element = lineElement.codeElement || lineElement.element; + if (element && !seen.has(element)) { + seen.add(element); + elements.push(element); + } + } + return elements; +} + +function createChangeIndicatorElement(indicator: MarkdownPreviewChangeIndicator): HTMLDivElement { const wrapper = document.createElement('div'); wrapper.className = `diff-change-indicator diff-change-indicator-${indicator.type}`; wrapper.setAttribute('data-original-line-count', String(indicator.originalLineCount)); const arrowLine = document.createElement('span'); arrowLine.className = 'diff-change-indicator-arrow'; - const tooltip = createDiffTooltip(indicator.originalContent, indicator.modifiedContent); + const tooltip = createDiffTooltip(indicator); arrowLine.appendChild(tooltip); wrapper.appendChild(arrowLine); return wrapper; } -function addModificationGutterBar(element: HTMLElement, indicator?: { originalContent: string; modifiedContent: string }): void { +function addModificationGutterBar(element: HTMLElement, indicator?: MarkdownPreviewChangeIndicator): void { const gutter = document.createElement('div'); gutter.className = 'diff-modification-gutter'; if (indicator) { - const tooltip = createDiffTooltip(indicator.originalContent, indicator.modifiedContent); + const tooltip = createDiffTooltip(indicator); gutter.appendChild(tooltip); } @@ -447,201 +467,104 @@ function addModificationGutterBar(element: HTMLElement, indicator?: { originalCo element.appendChild(gutter); } -function createDiffTooltip(originalContent: string, modifiedContent: string): HTMLDivElement { +function createDiffTooltip(indicator: MarkdownPreviewChangeIndicator): HTMLDivElement { const tooltip = document.createElement('div'); tooltip.className = 'diff-change-indicator-tooltip'; - const originalLines = originalContent ? originalContent.split('\n') : []; - const modifiedLines = modifiedContent ? modifiedContent.split('\n') : []; - const lineDiff = computeLineLCS(originalLines, modifiedLines); - - for (const entry of lineDiff) { - if (entry.type === 'equal') { - // Show unchanged lines in both sections with no highlight - const section = document.createElement('div'); - section.className = 'diff-tooltip-unchanged'; - const pre = document.createElement('pre'); - pre.textContent = entry.text; - section.appendChild(pre); - tooltip.appendChild(section); - } else if (entry.type === 'delete') { - const section = document.createElement('div'); - section.className = 'diff-tooltip-deleted'; - const pre = document.createElement('pre'); - pre.textContent = entry.text; - section.appendChild(pre); - tooltip.appendChild(section); - } else if (entry.type === 'insert') { - const section = document.createElement('div'); - section.className = 'diff-tooltip-added'; - const pre = document.createElement('pre'); - pre.textContent = entry.text; - section.appendChild(pre); - tooltip.appendChild(section); - } else if (entry.type === 'modify') { - // Show old and new with word-level highlights - const delSection = document.createElement('div'); - delSection.className = 'diff-tooltip-deleted'; - const delPre = document.createElement('pre'); - appendInlineHighlights(delPre, entry.oldTokens!, entry.newTokens!, 'delete'); - delSection.appendChild(delPre); - tooltip.appendChild(delSection); - - const addSection = document.createElement('div'); - addSection.className = 'diff-tooltip-added'; - const addPre = document.createElement('pre'); - appendInlineHighlights(addPre, entry.oldTokens!, entry.newTokens!, 'insert'); - addSection.appendChild(addPre); - tooltip.appendChild(addSection); - } + if (indicator.originalContent) { + appendDiffTooltipSection(tooltip, 'diff-tooltip-deleted', indicator.originalContent, indicator.originalInnerChanges, 'diff-tooltip-inner-deleted'); + } + if (indicator.modifiedContent) { + appendDiffTooltipSection(tooltip, 'diff-tooltip-added', indicator.modifiedContent, indicator.modifiedInnerChanges, 'diff-tooltip-inner-added'); } return tooltip; } -interface DiffEntry { - type: 'equal' | 'delete' | 'insert' | 'modify'; - text: string; - oldTokens?: string[]; - newTokens?: string[]; +function appendDiffTooltipSection(tooltip: HTMLElement, className: string, content: string, innerChanges: readonly MarkdownPreviewInnerChange[] | undefined, innerChangeClassName: string): void { + const section = document.createElement('div'); + section.className = className; + const pre = document.createElement('pre'); + appendDiffTooltipContent(pre, content, innerChanges, innerChangeClassName); + section.appendChild(pre); + tooltip.appendChild(section); } -/** - * Compute a line-level diff using LCS, grouping adjacent changed lines. - * When a block has both deletions and insertions, pairs them as 'modify' for inline highlighting. - */ -function computeLineLCS(oldLines: string[], newLines: string[]): DiffEntry[] { - const m = oldLines.length; - const n = newLines.length; - - // Build LCS table - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - if (oldLines[i - 1] === newLines[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]); - } - } +function appendDiffTooltipContent(container: HTMLElement, content: string, innerChanges: readonly MarkdownPreviewInnerChange[] | undefined, innerChangeClassName: string): void { + if (!innerChanges?.length) { + container.textContent = content; + return; } - // Backtrack to get diff operations - const rawDiff: Array<{ type: 'equal' | 'delete' | 'insert'; line: string }> = []; - let i = m, j = n; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { - rawDiff.push({ type: 'equal', line: oldLines[i - 1] }); - i--; j--; - } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { - rawDiff.push({ type: 'insert', line: newLines[j - 1] }); - j--; - } else { - rawDiff.push({ type: 'delete', line: oldLines[i - 1] }); - i--; + const innerChangesByLine = groupInnerChangesByLine(innerChanges); + const lines = content.split('\n'); + for (let line = 0; line < lines.length; ++line) { + appendDiffTooltipLine(container, lines[line], innerChangesByLine.get(line), innerChangeClassName); + if (line + 1 < lines.length) { + container.appendChild(document.createTextNode('\n')); } } - rawDiff.reverse(); - - // Group into entries, pairing adjacent delete/insert blocks as 'modify' - const result: DiffEntry[] = []; - let idx = 0; - while (idx < rawDiff.length) { - const item = rawDiff[idx]; - if (item.type === 'equal') { - result.push({ type: 'equal', text: item.line }); - idx++; - } else { - // Collect contiguous changed block - const delLines: string[] = []; - const insLines: string[] = []; - while (idx < rawDiff.length && rawDiff[idx].type !== 'equal') { - if (rawDiff[idx].type === 'delete') { - delLines.push(rawDiff[idx].line); - } else { - insLines.push(rawDiff[idx].line); - } - idx++; - } - - // Pair up lines for inline highlighting - const paired = Math.min(delLines.length, insLines.length); - for (let k = 0; k < paired; k++) { - result.push({ - type: 'modify', - text: '', - oldTokens: tokenize(delLines[k]), - newTokens: tokenize(insLines[k]), - }); - } - // Remaining unpaired lines - for (let k = paired; k < delLines.length; k++) { - result.push({ type: 'delete', text: delLines[k] }); - } - for (let k = paired; k < insLines.length; k++) { - result.push({ type: 'insert', text: insLines[k] }); - } - } - } - - return result; } -/** Split a line into alternating word and whitespace tokens */ -function tokenize(line: string): string[] { - return line.match(/\S+|\s+/g) || ['']; +function appendDiffTooltipLine(container: HTMLElement, lineText: string, innerChanges: readonly MarkdownPreviewInnerChange[] | undefined, innerChangeClassName: string): void { + const normalizedInnerChanges = normalizeInnerChanges(innerChanges, lineText.length); + let offset = 0; + for (const change of normalizedInnerChanges) { + if (offset < change.startColumn) { + container.appendChild(document.createTextNode(lineText.slice(offset, change.startColumn))); + } + + const span = document.createElement('span'); + span.className = innerChangeClassName; + span.textContent = lineText.slice(change.startColumn, change.endColumn); + container.appendChild(span); + offset = change.endColumn; + } + + if (offset < lineText.length) { + container.appendChild(document.createTextNode(lineText.slice(offset))); + } } -/** Compute LCS indices for two token arrays */ -function tokenLCS(a: string[], b: string[]): Set[] { - const m = a.length, n = b.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - if (a[i - 1] === b[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]); - } - } - } - const aMatch = new Set(); - const bMatch = new Set(); - let i = m, j = n; - while (i > 0 && j > 0) { - if (a[i - 1] === b[j - 1]) { - aMatch.add(i - 1); - bMatch.add(j - 1); - i--; j--; - } else if (dp[i][j - 1] >= dp[i - 1][j]) { - j--; +function groupInnerChangesByLine(innerChanges: readonly MarkdownPreviewInnerChange[]): Map { + const groupedInnerChanges = new Map(); + for (const change of innerChanges) { + const lineChanges = groupedInnerChanges.get(change.line); + if (lineChanges) { + lineChanges.push(change); } else { - i--; + groupedInnerChanges.set(change.line, [change]); } } - return [aMatch, bMatch]; + return groupedInnerChanges; } -/** - * Append tokens to a
 element with inline highlights for changed tokens.
- * @param side 'delete' renders oldTokens highlighting removals; 'insert' renders newTokens highlighting additions
- */
-function appendInlineHighlights(pre: HTMLPreElement, oldTokens: string[], newTokens: string[], side: 'delete' | 'insert'): void {
-	const [oldMatch, newMatch] = tokenLCS(oldTokens, newTokens);
-	const tokens = side === 'delete' ? oldTokens : newTokens;
-	const matchSet = side === 'delete' ? oldMatch : newMatch;
-	const highlightClass = side === 'delete' ? 'diff-inline-deleted' : 'diff-inline-added';
+function normalizeInnerChanges(innerChanges: readonly MarkdownPreviewInnerChange[] | undefined, lineLength: number): { startColumn: number; endColumn: number }[] {
+	if (!innerChanges?.length) {
+		return [];
+	}
 
-	for (let i = 0; i < tokens.length; i++) {
-		if (matchSet.has(i)) {
-			pre.appendChild(document.createTextNode(tokens[i]));
+	const sortedInnerChanges = innerChanges
+		.map(change => ({
+			startColumn: clampColumn(change.startColumn, lineLength),
+			endColumn: clampColumn(change.endColumn, lineLength),
+		}))
+		.filter(change => change.startColumn < change.endColumn)
+		.sort((a, b) => a.startColumn - b.startColumn || a.endColumn - b.endColumn);
+	const normalizedInnerChanges: { startColumn: number; endColumn: number }[] = [];
+	for (const change of sortedInnerChanges) {
+		const previous = normalizedInnerChanges[normalizedInnerChanges.length - 1];
+		if (previous && change.startColumn <= previous.endColumn) {
+			previous.endColumn = Math.max(previous.endColumn, change.endColumn);
 		} else {
-			const span = document.createElement('span');
-			span.className = highlightClass;
-			span.textContent = tokens[i];
-			pre.appendChild(span);
+			normalizedInnerChanges.push(change);
 		}
 	}
+	return normalizedInnerChanges;
+}
+
+function clampColumn(column: number, lineLength: number): number {
+	return Math.min(Math.max(column, 0), lineLength);
 }
 
 
@@ -666,19 +589,11 @@ function applyInnerChangeHighlights(lineChanges: MarkdownPreviewLineChanges | un
 		return;
 	}
 
-	let i = 0;
-	while (true) {
-		const startMarker = root.querySelector(`[data-diff-start="${i}"]`);
-		const endMarker = root.querySelector(`[data-diff-end="${i}"]`);
-		if (!startMarker || !endMarker) {
-			break;
-		}
-
+	for (const { startMarker, endMarker } of getDiffMarkerPairs(root)) {
 		const range = new Range();
 		range.setStartAfter(startMarker);
 		range.setEndBefore(endMarker);
 		ranges.push(range);
-		i++;
 	}
 
 	if (ranges.length > 0) {
@@ -686,6 +601,31 @@ function applyInnerChangeHighlights(lineChanges: MarkdownPreviewLineChanges | un
 	}
 }
 
+interface DiffMarkerPair {
+	readonly startMarker: Element;
+	readonly endMarker: Element;
+}
+
+function getDiffMarkerPairs(root: Element): DiffMarkerPair[] {
+	const endMarkersById = new Map();
+	for (const endMarker of root.querySelectorAll('[data-diff-end]')) {
+		const id = endMarker.getAttribute('data-diff-end');
+		if (id !== null) {
+			endMarkersById.set(id, endMarker);
+		}
+	}
+
+	const pairs: DiffMarkerPair[] = [];
+	for (const startMarker of root.querySelectorAll('[data-diff-start]')) {
+		const id = startMarker.getAttribute('data-diff-start');
+		const endMarker = id === null ? undefined : endMarkersById.get(id);
+		if (endMarker && !(startMarker.compareDocumentPosition(endMarker) & Node.DOCUMENT_POSITION_PRECEDING)) {
+			pairs.push({ startMarker, endMarker });
+		}
+	}
+	return pairs;
+}
+
 
 
 document.addEventListener('dblclick', event => {
diff --git a/extensions/markdown-language-features/preview-src/scroll-sync.ts b/extensions/markdown-language-features/preview-src/scroll-sync.ts
index f4eae58d47c..6c684ea9c14 100644
--- a/extensions/markdown-language-features/preview-src/scroll-sync.ts
+++ b/extensions/markdown-language-features/preview-src/scroll-sync.ts
@@ -99,6 +99,29 @@ export function getElementsForSourceLine(targetLine: number, documentVersion: nu
 	return { previous };
 }
 
+export function getElementsForSourceLineRange(startLine: number, endLine: number, documentVersion: number): readonly CodeLineElement[] {
+	const rangeStart = Math.floor(startLine);
+	const rangeEnd = Math.max(rangeStart + 1, Math.ceil(endLine));
+	const lines = getCodeLineElements(documentVersion).filter(element => element.line >= 0);
+	const elements: CodeLineElement[] = [];
+	for (let i = 0; i < lines.length; i++) {
+		const element = lines[i];
+		const next = lines[i + 1];
+		const elementStart = element.line;
+		const elementEnd = element.endLine !== undefined
+			? element.endLine + 1
+			: (next && next.line > element.line ? next.line : element.line + 1);
+		if (rangesIntersect(rangeStart, rangeEnd, elementStart, elementEnd)) {
+			elements.push(element);
+		}
+	}
+	return elements;
+}
+
+function rangesIntersect(startA: number, endA: number, startB: number, endB: number): boolean {
+	return startA < endB && startB < endA;
+}
+
 /**
  * Find the html elements that are at a specific pixel offset on the page.
  */
diff --git a/extensions/markdown-language-features/preview-src/tsconfig.json b/extensions/markdown-language-features/preview-src/tsconfig.json
index 5c51c32b141..95beb29e157 100644
--- a/extensions/markdown-language-features/preview-src/tsconfig.json
+++ b/extensions/markdown-language-features/preview-src/tsconfig.json
@@ -1,7 +1,7 @@
 {
 	"extends": "../../tsconfig.base.json",
 	"compilerOptions": {
-		"rootDir": ".",
+		"rootDir": "..",
 		"outDir": "./dist/",
 		"jsx": "react",
 		"esModuleInterop": true,
diff --git a/extensions/markdown-language-features/src/preview/lineDiff.ts b/extensions/markdown-language-features/src/preview/lineDiff.ts
index d6d577dcb58..bd5c1d98781 100644
--- a/extensions/markdown-language-features/src/preview/lineDiff.ts
+++ b/extensions/markdown-language-features/src/preview/lineDiff.ts
@@ -21,6 +21,8 @@ interface LineMappings {
 	readonly modifiedToOriginal: number[];
 }
 
+type ChangedLineRange = Pick;
+
 export class MarkdownPreviewLineDiffProvider {
 
 	readonly #originalDocument: vscode.TextDocument;
@@ -92,7 +94,7 @@ async function computeLineChanges(originalDocument: vscode.TextDocument, modifie
 	const deleted: number[] = [];
 	const originalInnerChanges: MarkdownPreviewInnerChange[] = [];
 	const modifiedInnerChanges: MarkdownPreviewInnerChange[] = [];
-	const changeIndicators: MarkdownPreviewChangeIndicator[] = [];
+	const changedLineRanges: ChangedLineRange[] = [];
 	const mappings = createEmptyLineMappings(originalLineCount, modifiedLineCount);
 
 	let lastOriginalEnd = 0;
@@ -120,22 +122,7 @@ async function computeLineChanges(originalDocument: vscode.TextDocument, modifie
 		// Collect change indicators for deletions and modifications
 		const origChangedCount = origEnd - origStart;
 		if (origChangedCount > 0) {
-			const originalLines: string[] = [];
-			for (let i = origStart; i < origEnd; ++i) {
-				originalLines.push(originalDocument.lineAt(i).text);
-			}
-			const modifiedLines: string[] = [];
-			for (let i = modStart; i < modEnd; ++i) {
-				modifiedLines.push(modifiedDocument.lineAt(i).text);
-			}
-			changeIndicators.push({
-				modifiedLine: modStart,
-				modifiedLineCount: modEnd - modStart,
-				originalLineCount: origChangedCount,
-				originalContent: originalLines.join('\n'),
-				modifiedContent: modifiedLines.join('\n'),
-				type: modEnd === modStart ? 'deletion' : 'modification',
-			});
+			changedLineRanges.push(change);
 		}
 
 		// Collect inner changes (character-level changes within modified lines)
@@ -153,10 +140,88 @@ async function computeLineChanges(originalDocument: vscode.TextDocument, modifie
 	// Map unchanged lines after the last change
 	fillUnchangedLineMappings(mappings, lastOriginalEnd, originalLineCount, lastModifiedEnd, modifiedLineCount);
 	fillMissingLineMappings(mappings);
+	const splitChangedLineRanges = splitChangedLineRangesByMarkdownBlocks(changedLineRanges, originalDocument, modifiedDocument);
+	const changeIndicators = createChangeIndicators(splitChangedLineRanges, originalDocument, modifiedDocument, originalInnerChanges, modifiedInnerChanges);
 
 	return { added, deleted, originalInnerChanges, modifiedInnerChanges, changeIndicators, ...mappings };
 }
 
+function createChangeIndicators(ranges: readonly ChangedLineRange[], originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument, originalInnerChanges: readonly MarkdownPreviewInnerChange[], modifiedInnerChanges: readonly MarkdownPreviewInnerChange[]): MarkdownPreviewChangeIndicator[] {
+	return ranges.map(range => {
+		const modifiedLineCount = range.modifiedRange.end.line - range.modifiedRange.start.line;
+		return {
+			modifiedLine: range.modifiedRange.start.line,
+			modifiedLineCount,
+			originalLineCount: range.originalRange.end.line - range.originalRange.start.line,
+			originalContent: getLineRangeText(originalDocument, range.originalRange),
+			originalInnerChanges: getRelativeInnerChanges(originalInnerChanges, range.originalRange),
+			modifiedContent: getLineRangeText(modifiedDocument, range.modifiedRange),
+			modifiedInnerChanges: getRelativeInnerChanges(modifiedInnerChanges, range.modifiedRange),
+			type: modifiedLineCount === 0 ? 'deletion' : 'modification',
+		};
+	});
+}
+
+function getRelativeInnerChanges(innerChanges: readonly MarkdownPreviewInnerChange[], range: vscode.Range): MarkdownPreviewInnerChange[] | undefined {
+	const relativeInnerChanges: MarkdownPreviewInnerChange[] = [];
+	for (const change of innerChanges) {
+		if (change.line >= range.start.line && change.line < range.end.line) {
+			relativeInnerChanges.push({
+				line: change.line - range.start.line,
+				startColumn: change.startColumn,
+				endColumn: change.endColumn,
+			});
+		}
+	}
+	return relativeInnerChanges.length ? relativeInnerChanges : undefined;
+}
+
+function splitChangedLineRangesByMarkdownBlocks(ranges: readonly ChangedLineRange[], originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument): ChangedLineRange[] {
+	const splitRanges: ChangedLineRange[] = [];
+	for (const range of ranges) {
+		const originalBlocks = getNonBlankLineRanges(originalDocument, range.originalRange);
+		const modifiedBlocks = getNonBlankLineRanges(modifiedDocument, range.modifiedRange);
+		if (originalBlocks.length > 1 && originalBlocks.length === modifiedBlocks.length) {
+			for (let i = 0; i < originalBlocks.length; ++i) {
+				splitRanges.push({
+					originalRange: originalBlocks[i],
+					modifiedRange: modifiedBlocks[i],
+				});
+			}
+		} else {
+			splitRanges.push(range);
+		}
+	}
+	return splitRanges;
+}
+
+function getNonBlankLineRanges(document: vscode.TextDocument, range: vscode.Range): vscode.Range[] {
+	const ranges: vscode.Range[] = [];
+	let blockStartLine: number | undefined;
+	for (let line = range.start.line; line < range.end.line; ++line) {
+		if (document.lineAt(line).text.trim().length === 0) {
+			if (blockStartLine !== undefined) {
+				ranges.push(new vscode.Range(blockStartLine, 0, line, 0));
+				blockStartLine = undefined;
+			}
+		} else if (blockStartLine === undefined) {
+			blockStartLine = line;
+		}
+	}
+	if (blockStartLine !== undefined) {
+		ranges.push(new vscode.Range(blockStartLine, 0, range.end.line, 0));
+	}
+	return ranges;
+}
+
+function getLineRangeText(document: vscode.TextDocument, range: vscode.Range): string {
+	const lines: string[] = [];
+	for (let line = range.start.line; line < range.end.line; ++line) {
+		lines.push(document.lineAt(line).text);
+	}
+	return lines.join('\n');
+}
+
 /**
  * Splits a Range into per-line inner change entries.
  * For single-line ranges, emits one entry. For multi-line ranges,
diff --git a/extensions/markdown-language-features/types/previewMessaging.d.ts b/extensions/markdown-language-features/types/previewMessaging.d.ts
index d0568b5a44e..2258f8ea23d 100644
--- a/extensions/markdown-language-features/types/previewMessaging.d.ts
+++ b/extensions/markdown-language-features/types/previewMessaging.d.ts
@@ -25,8 +25,12 @@ export interface MarkdownPreviewChangeIndicator {
 	readonly originalLineCount: number;
 	/** The original text content that was deleted or replaced */
 	readonly originalContent: string;
+	/** Character-level changes within originalContent, using originalContent-relative line numbers */
+	readonly originalInnerChanges?: readonly MarkdownPreviewInnerChange[];
 	/** The modified text content (for modifications) */
 	readonly modifiedContent: string;
+	/** Character-level changes within modifiedContent, using modifiedContent-relative line numbers */
+	readonly modifiedInnerChanges?: readonly MarkdownPreviewInnerChange[];
 	/** Whether this is a pure deletion or a modification of existing lines */
 	readonly type: 'deletion' | 'modification';
 }