Fix some bugs and clean up how markdown change indicators are rendered

This commit is contained in:
Matt Bierner
2026-05-19 22:23:55 -07:00
parent af6d574cf3
commit cc02f7d4ce
6 changed files with 282 additions and 246 deletions
@@ -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: "";
@@ -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<HTMLElement>();
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<HTMLElement>();
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<number>[] {
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<number>();
const bMatch = new Set<number>();
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<number, readonly MarkdownPreviewInnerChange[]> {
const groupedInnerChanges = new Map<number, MarkdownPreviewInnerChange[]>();
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 <pre> 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<string, Element>();
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 => {
@@ -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.
*/
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"rootDir": "..",
"outDir": "./dist/",
"jsx": "react",
"esModuleInterop": true,
@@ -21,6 +21,8 @@ interface LineMappings {
readonly modifiedToOriginal: number[];
}
type ChangedLineRange = Pick<vscode.TextDiffChange, 'originalRange' | 'modifiedRange'>;
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,
@@ -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';
}