mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-17 13:50:46 +01:00
f856c4c486
For #315174 Adding better inline diff indicators
842 lines
26 KiB
TypeScript
842 lines
26 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { ActiveLineMarker } from './activeLineMarker';
|
|
import { onceDocumentLoaded } from './events';
|
|
import { createPosterForVsCode } from './messaging';
|
|
import { getEditorLineNumberForPageOffset, getElementsForSourceLine, 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 { isOfScheme, Schemes } from '../src/util/schemes';
|
|
import { DiffScrollSyncManager } from './diffScrollSync';
|
|
|
|
let scrollDisabledCount = 0;
|
|
let scrollDisabledTimer: number | undefined;
|
|
|
|
const marker = new ActiveLineMarker();
|
|
const settings = new SettingsManager();
|
|
|
|
let documentVersion = 0;
|
|
let documentResource = settings.settings.source;
|
|
let lineChanges = settings.settings.lineChanges;
|
|
|
|
const vscode = acquireVsCodeApi();
|
|
|
|
const onDiffScroll = (mappedLine: number) => {
|
|
scrollDisabledCount = 1;
|
|
if (scrollDisabledTimer) {
|
|
clearTimeout(scrollDisabledTimer);
|
|
}
|
|
scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 100);
|
|
doAfterImagesLoaded(() => scrollToRevealSourceLine(mappedLine, documentVersion, settings));
|
|
};
|
|
const diffScrollSyncManager = settings.settings.diffScrollSync
|
|
? new DiffScrollSyncManager(settings.settings.diffScrollSync, onDiffScroll)
|
|
: undefined;
|
|
|
|
interface State {
|
|
scrollProgress?: number;
|
|
resource?: string;
|
|
line?: number;
|
|
fragment?: string;
|
|
}
|
|
|
|
const originalState: State = vscode.getState() ?? {};
|
|
const state: State = {
|
|
...originalState,
|
|
...getData<Partial<State>>('data-state')
|
|
};
|
|
|
|
const hasStartingLine = typeof settings.settings.line === 'number' && !isNaN(settings.settings.line);
|
|
if (typeof originalState.scrollProgress !== 'undefined'
|
|
&& (originalState?.resource !== state.resource || (hasStartingLine && originalState.line !== settings.settings.line))) {
|
|
state.scrollProgress = undefined;
|
|
}
|
|
|
|
// Make sure to sync VS Code state here
|
|
vscode.setState(state);
|
|
|
|
const messaging = createPosterForVsCode(vscode, settings);
|
|
|
|
window.cspAlerter.setPoster(messaging);
|
|
window.styleLoadingMonitor.setPoster(messaging);
|
|
|
|
|
|
function doAfterImagesLoaded(cb: () => void) {
|
|
const imgElements = document.getElementsByTagName('img');
|
|
if (imgElements.length > 0) {
|
|
const ps = Array.from(imgElements, e => {
|
|
if (e.complete) {
|
|
return Promise.resolve();
|
|
} else {
|
|
return new Promise<void>((resolve) => {
|
|
e.addEventListener('load', () => resolve());
|
|
e.addEventListener('error', () => resolve());
|
|
});
|
|
}
|
|
});
|
|
Promise.all(ps).then(() => setTimeout(cb, 0));
|
|
} else {
|
|
setTimeout(cb, 0);
|
|
}
|
|
}
|
|
|
|
onceDocumentLoaded(() => {
|
|
// Load initial html
|
|
const htmlParser = new DOMParser();
|
|
const markDownHtml = htmlParser.parseFromString(
|
|
getRawData('data-initial-md-content'),
|
|
'text/html'
|
|
);
|
|
|
|
const newElements = [...markDownHtml.body.children];
|
|
document.body.append(...newElements);
|
|
for (const el of newElements) {
|
|
if (el instanceof HTMLElement) {
|
|
domEval(el);
|
|
}
|
|
}
|
|
|
|
// Restore
|
|
const scrollProgress = state.scrollProgress;
|
|
addImageContexts();
|
|
applyLineChanges(lineChanges);
|
|
if (typeof scrollProgress === 'number' && !settings.settings.fragment) {
|
|
doAfterImagesLoaded(() => {
|
|
scrollDisabledCount = 1;
|
|
if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); }
|
|
scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200);
|
|
// Always set scroll of at least 1 to prevent VS Code's webview code from auto scrolling us
|
|
const scrollToY = Math.max(1, scrollProgress * document.body.clientHeight);
|
|
window.scrollTo(0, scrollToY);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (settings.settings.scrollPreviewWithEditor) {
|
|
doAfterImagesLoaded(() => {
|
|
// Try to scroll to fragment if available
|
|
if (settings.settings.fragment) {
|
|
let fragment: string;
|
|
try {
|
|
fragment = encodeURIComponent(settings.settings.fragment);
|
|
} catch {
|
|
fragment = settings.settings.fragment;
|
|
}
|
|
state.fragment = undefined;
|
|
vscode.setState(state);
|
|
|
|
const element = getLineElementForFragment(fragment, documentVersion);
|
|
if (element) {
|
|
scrollDisabledCount = 1;
|
|
if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); }
|
|
scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200);
|
|
scrollToRevealSourceLine(element.line, documentVersion, settings);
|
|
}
|
|
} else {
|
|
if (!isNaN(settings.settings.line!)) {
|
|
scrollDisabledCount = 1;
|
|
if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); }
|
|
scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200);
|
|
scrollToRevealSourceLine(settings.settings.line!, documentVersion, settings);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (typeof settings.settings.selectedLine === 'number') {
|
|
marker.onDidChangeTextEditorSelection(settings.settings.selectedLine, documentVersion);
|
|
}
|
|
});
|
|
|
|
const onUpdateView = (() => {
|
|
const doScroll = throttle((line: number) => {
|
|
scrollDisabledCount = 1;
|
|
if (scrollDisabledTimer) {
|
|
clearTimeout(scrollDisabledTimer);
|
|
}
|
|
scrollDisabledTimer = window.setTimeout(() => {
|
|
scrollDisabledCount = 0;
|
|
}, 50);
|
|
doAfterImagesLoaded(() => scrollToRevealSourceLine(line, documentVersion, settings));
|
|
}, 50);
|
|
|
|
return (line: number) => {
|
|
if (!isNaN(line)) {
|
|
state.line = line;
|
|
|
|
doScroll(line);
|
|
}
|
|
};
|
|
})();
|
|
|
|
window.addEventListener('resize', () => {
|
|
scrollDisabledCount = 1;
|
|
if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); }
|
|
scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200);
|
|
updateScrollProgress();
|
|
}, true);
|
|
|
|
function addImageContexts() {
|
|
const images = document.getElementsByTagName('img');
|
|
let idNumber = 0;
|
|
for (const img of images) {
|
|
img.id = 'image-' + idNumber;
|
|
idNumber += 1;
|
|
const imageSource = img.getAttribute('data-src');
|
|
const isLocalFile = imageSource && !(isOfScheme(Schemes.http, imageSource) || isOfScheme(Schemes.https, imageSource));
|
|
const webviewSection = isLocalFile ? 'localImage' : 'image';
|
|
img.setAttribute('data-vscode-context', JSON.stringify({ webviewSection, id: img.id, 'preventDefaultContextMenuItems': true, resource: documentResource, imageSource }));
|
|
}
|
|
}
|
|
|
|
async function copyImage(image: HTMLImageElement, retries = 5) {
|
|
if (!document.hasFocus() && retries > 0) {
|
|
// copyImage is called at the same time as webview.reveal, which means this function is running whilst the webview is gaining focus.
|
|
// Since navigator.clipboard.write requires the document to be focused, we need to wait for focus.
|
|
// We cannot use a listener, as there is a high chance the focus is gained during the setup of the listener resulting in us missing it.
|
|
setTimeout(() => { copyImage(image, retries - 1); }, 20);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.write([new ClipboardItem({
|
|
'image/png': new Promise((resolve) => {
|
|
const canvas = document.createElement('canvas');
|
|
if (canvas !== null) {
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
const context = canvas.getContext('2d');
|
|
context?.drawImage(image, 0, 0);
|
|
}
|
|
canvas.toBlob((blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
}
|
|
canvas.remove();
|
|
}, 'image/png');
|
|
})
|
|
})]);
|
|
} catch (e) {
|
|
console.error(e);
|
|
const selection = window.getSelection();
|
|
if (!selection) {
|
|
await navigator.clipboard.writeText(image.getAttribute('data-src') ?? image.src);
|
|
return;
|
|
}
|
|
selection.removeAllRanges();
|
|
const range = document.createRange();
|
|
range.selectNode(image);
|
|
selection.addRange(range);
|
|
document.execCommand('copy');
|
|
selection.removeAllRanges();
|
|
}
|
|
}
|
|
|
|
window.addEventListener('message', async event => {
|
|
const data = event.data as ToWebviewMessage.Type;
|
|
switch (data.type) {
|
|
case 'copyImage': {
|
|
const img = document.getElementById(data.id);
|
|
if (img instanceof HTMLImageElement) {
|
|
copyImage(img);
|
|
}
|
|
return;
|
|
}
|
|
case 'onDidChangeTextEditorSelection':
|
|
if (data.source === documentResource) {
|
|
marker.onDidChangeTextEditorSelection(data.line, documentVersion);
|
|
}
|
|
return;
|
|
|
|
case 'updateView':
|
|
if (data.source === documentResource) {
|
|
onUpdateView(data.line);
|
|
}
|
|
return;
|
|
|
|
case 'updateContent': {
|
|
lineChanges = data.lineChanges;
|
|
if (data.diffScrollSync) {
|
|
diffScrollSyncManager?.update(data.diffScrollSync);
|
|
}
|
|
const root = document.querySelector('.markdown-body')!;
|
|
|
|
const parser = new DOMParser();
|
|
const newContent = parser.parseFromString(data.content, 'text/html'); // CodeQL [SM03712] This renderers content from the workspace into the Markdown preview. Webviews (and the markdown preview) have many other security measures in place to make this safe
|
|
|
|
// Strip out meta http-equiv tags
|
|
for (const metaElement of Array.from(newContent.querySelectorAll('meta'))) {
|
|
if (metaElement.hasAttribute('http-equiv')) {
|
|
metaElement.remove();
|
|
}
|
|
}
|
|
|
|
if (data.source !== documentResource) {
|
|
documentResource = data.source;
|
|
const newBody = newContent.querySelector('.markdown-body')!;
|
|
root.replaceWith(newBody);
|
|
domEval(newBody);
|
|
} else {
|
|
const newRoot = newContent.querySelector('.markdown-body')!;
|
|
|
|
// Move styles to head
|
|
// This prevents an ugly flash of unstyled content
|
|
const styles = newRoot.querySelectorAll('link');
|
|
for (const style of styles) {
|
|
style.remove();
|
|
}
|
|
newRoot.prepend(...styles);
|
|
|
|
morphdom(root, newRoot, {
|
|
childrenOnly: true,
|
|
onBeforeElUpdated: (fromEl: Element, toEl: Element) => {
|
|
if (areNodesEqual(fromEl, toEl)) {
|
|
// areEqual doesn't look at `data-line` so copy those over manually
|
|
const fromLines = fromEl.querySelectorAll('[data-line]');
|
|
const toLines = toEl.querySelectorAll('[data-line]');
|
|
if (fromLines.length !== toLines.length) {
|
|
console.log('unexpected line number change');
|
|
}
|
|
|
|
for (let i = 0; i < fromLines.length; ++i) {
|
|
const fromChild = fromLines[i];
|
|
const toChild = toLines[i];
|
|
if (toChild) {
|
|
fromChild.setAttribute('data-line', toChild.getAttribute('data-line')!);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (fromEl.tagName === 'DETAILS' && toEl.tagName === 'DETAILS') {
|
|
if (fromEl.hasAttribute('open')) {
|
|
toEl.setAttribute('open', '');
|
|
}
|
|
}
|
|
|
|
return true;
|
|
},
|
|
addChild: (parentNode: Node, childNode: Node) => {
|
|
parentNode.appendChild(childNode);
|
|
if (childNode instanceof HTMLElement) {
|
|
domEval(childNode);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
++documentVersion;
|
|
|
|
window.dispatchEvent(new CustomEvent('vscode.markdown.updateContent'));
|
|
addImageContexts();
|
|
applyLineChanges(lineChanges);
|
|
break;
|
|
}
|
|
}
|
|
}, false);
|
|
|
|
function applyLineChanges(lineChanges: MarkdownPreviewLineChanges | undefined): void {
|
|
for (const element of document.querySelectorAll('.code-line-diff-added, .code-line-diff-deleted, .code-line-diff-modified')) {
|
|
element.classList.remove('code-line-diff', 'code-line-diff-added', 'code-line-diff-deleted', 'code-line-diff-modified');
|
|
}
|
|
|
|
// Remove previous change indicators
|
|
for (const element of document.querySelectorAll('.diff-change-indicator')) {
|
|
element.remove();
|
|
}
|
|
|
|
// Remove previous modification gutter bars
|
|
for (const element of document.querySelectorAll('.diff-modification-gutter')) {
|
|
element.remove();
|
|
}
|
|
|
|
markChangedLines(lineChanges?.added, 'code-line-diff-added');
|
|
markChangedLines(lineChanges?.deleted, 'code-line-diff-deleted');
|
|
|
|
applyChangeIndicators(lineChanges);
|
|
applyInnerChangeHighlights(lineChanges);
|
|
}
|
|
|
|
function markChangedLines(lines: readonly number[] | undefined, className: string): void {
|
|
if (!lines) {
|
|
return;
|
|
}
|
|
|
|
for (const line of lines) {
|
|
const { previous, next } = getElementsForSourceLine(line, documentVersion);
|
|
const lineElement = previous.line >= 0 ? previous : next;
|
|
const element = lineElement?.codeElement || lineElement?.element;
|
|
if (element) {
|
|
element.classList.add('code-line-diff', className);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function applyChangeIndicators(lineChanges: MarkdownPreviewLineChanges | undefined): void {
|
|
if (!lineChanges?.changeIndicators?.length) {
|
|
return;
|
|
}
|
|
|
|
for (const indicator of lineChanges.changeIndicators) {
|
|
const { previous, next } = getElementsForSourceLine(indicator.modifiedLine, documentVersion);
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createChangeIndicatorElement(indicator: { type: string; originalLineCount: number; originalContent: string; modifiedContent: string }): 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);
|
|
arrowLine.appendChild(tooltip);
|
|
wrapper.appendChild(arrowLine);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
function addModificationGutterBar(element: HTMLElement, indicator?: { originalContent: string; modifiedContent: string }): void {
|
|
const gutter = document.createElement('div');
|
|
gutter.className = 'diff-modification-gutter';
|
|
|
|
if (indicator) {
|
|
const tooltip = createDiffTooltip(indicator.originalContent, indicator.modifiedContent);
|
|
gutter.appendChild(tooltip);
|
|
}
|
|
|
|
element.style.position = 'relative';
|
|
element.appendChild(gutter);
|
|
}
|
|
|
|
function createDiffTooltip(originalContent: string, modifiedContent: string): 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);
|
|
}
|
|
}
|
|
|
|
return tooltip;
|
|
}
|
|
|
|
interface DiffEntry {
|
|
type: 'equal' | 'delete' | 'insert' | 'modify';
|
|
text: string;
|
|
oldTokens?: string[];
|
|
newTokens?: string[];
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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--;
|
|
}
|
|
}
|
|
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) || [''];
|
|
}
|
|
|
|
/** 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--;
|
|
} else {
|
|
i--;
|
|
}
|
|
}
|
|
return [aMatch, bMatch];
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
if (matchSet.has(i)) {
|
|
pre.appendChild(document.createTextNode(tokens[i]));
|
|
} else {
|
|
const span = document.createElement('span');
|
|
span.className = highlightClass;
|
|
span.textContent = tokens[i];
|
|
pre.appendChild(span);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function applyInnerChangeHighlights(lineChanges: MarkdownPreviewLineChanges | undefined): void {
|
|
const diffHighlightAddedName = 'diff-inner-added';
|
|
const diffHighlightDeletedName = 'diff-inner-deleted';
|
|
|
|
// Clear previous highlights
|
|
CSS.highlights?.delete(diffHighlightAddedName);
|
|
CSS.highlights?.delete(diffHighlightDeletedName);
|
|
|
|
if (!lineChanges?.innerChanges?.length || !CSS.highlights) {
|
|
return;
|
|
}
|
|
|
|
const highlightName = lineChanges.added ? diffHighlightAddedName : diffHighlightDeletedName;
|
|
const ranges: Range[] = [];
|
|
|
|
// Find all marker pairs and create Range objects between them
|
|
const root = document.querySelector('.markdown-body');
|
|
if (!root) {
|
|
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;
|
|
}
|
|
|
|
const range = new Range();
|
|
range.setStartAfter(startMarker);
|
|
range.setEndBefore(endMarker);
|
|
ranges.push(range);
|
|
i++;
|
|
}
|
|
|
|
if (ranges.length > 0) {
|
|
CSS.highlights.set(highlightName, new Highlight(...ranges));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
document.addEventListener('dblclick', event => {
|
|
if (!settings.settings.doubleClickToSwitchToEditor) {
|
|
return;
|
|
}
|
|
|
|
// Disable double-click to switch editor for .copilotmd files
|
|
if (documentResource.endsWith('.copilotmd')) {
|
|
return;
|
|
}
|
|
|
|
// Ignore clicks on links
|
|
for (let node = event.target as HTMLElement; node; node = node.parentNode as HTMLElement) {
|
|
if (node.tagName === 'A') {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const offset = event.pageY;
|
|
const line = getEditorLineNumberForPageOffset(offset, documentVersion);
|
|
if (typeof line === 'number' && !isNaN(line)) {
|
|
messaging.postMessage('didClick', { line: Math.floor(line) });
|
|
}
|
|
});
|
|
|
|
const passThroughLinkSchemes = ['http:', 'https:', 'mailto:', 'vscode:', 'vscode-insiders:'];
|
|
|
|
document.addEventListener('click', event => {
|
|
if (!event) {
|
|
return;
|
|
}
|
|
|
|
let node = event.target as Element | null;
|
|
while (node) {
|
|
if (node.tagName && node.tagName === 'A' && (node as HTMLAnchorElement).href) {
|
|
if (node.getAttribute('href')?.startsWith('#')) {
|
|
return;
|
|
}
|
|
|
|
let hrefText = node.getAttribute('data-href');
|
|
if (!hrefText) {
|
|
hrefText = node.getAttribute('href');
|
|
// Pass through known schemes
|
|
if (hrefText && passThroughLinkSchemes.some(scheme => hrefText!.startsWith(scheme))) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If original link doesn't look like a url, delegate back to VS Code to resolve
|
|
if (hrefText && !/^[a-z\-]+:/i.test(hrefText)) {
|
|
messaging.postMessage('openLink', { href: hrefText });
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
}, true);
|
|
|
|
window.addEventListener('scroll', throttle(() => {
|
|
updateScrollProgress();
|
|
|
|
if (scrollDisabledCount > 0) {
|
|
return;
|
|
}
|
|
|
|
const line = getEditorLineNumberForPageOffset(window.scrollY, documentVersion);
|
|
if (typeof line === 'number' && !isNaN(line)) {
|
|
state.line = line;
|
|
vscode.setState(state);
|
|
messaging.postMessage('revealLine', { line });
|
|
diffScrollSyncManager?.broadcastScroll(line);
|
|
}
|
|
}, 50));
|
|
|
|
function updateScrollProgress() {
|
|
state.scrollProgress = window.scrollY / document.body.clientHeight;
|
|
vscode.setState(state);
|
|
}
|
|
|
|
|
|
/**
|
|
* Compares two nodes for morphdom to see if they are equal.
|
|
*
|
|
* This skips some attributes that should not cause equality to fail.
|
|
*/
|
|
function areNodesEqual(a: Element, b: Element): boolean {
|
|
const skippedAttrs = [
|
|
'open', // for details
|
|
];
|
|
|
|
if (a.isEqualNode(b)) {
|
|
return true;
|
|
}
|
|
|
|
if (a.tagName !== b.tagName || a.textContent !== b.textContent) {
|
|
return false;
|
|
}
|
|
|
|
const aAttrs = [...a.attributes].filter(attr => !skippedAttrs.includes(attr.name));
|
|
const bAttrs = [...b.attributes].filter(attr => !skippedAttrs.includes(attr.name));
|
|
if (aAttrs.length !== bAttrs.length) {
|
|
return false;
|
|
}
|
|
|
|
for (let i = 0; i < aAttrs.length; ++i) {
|
|
const aAttr = aAttrs[i];
|
|
const bAttr = bAttrs[i];
|
|
if (aAttr.name !== bAttr.name) {
|
|
return false;
|
|
}
|
|
if (aAttr.value !== bAttr.value && aAttr.name !== 'data-line') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const aChildren = Array.from(a.children);
|
|
const bChildren = Array.from(b.children);
|
|
|
|
return aChildren.length === bChildren.length && aChildren.every((x, i) => areNodesEqual(x, bChildren[i]));
|
|
}
|
|
|
|
|
|
function domEval(el: Element): void {
|
|
const preservedScriptAttributes: (keyof HTMLScriptElement)[] = [
|
|
'type', 'src', 'nonce', 'noModule', 'async',
|
|
];
|
|
|
|
const scriptNodes = el.tagName === 'SCRIPT' ? [el] : Array.from(el.getElementsByTagName('script'));
|
|
|
|
for (const node of scriptNodes) {
|
|
if (!(node instanceof HTMLElement)) {
|
|
continue;
|
|
}
|
|
|
|
const scriptTag = document.createElement('script');
|
|
const trustedScript = node.innerText;
|
|
scriptTag.text = trustedScript as string;
|
|
for (const key of preservedScriptAttributes) {
|
|
const val = node.getAttribute?.(key);
|
|
if (val) {
|
|
scriptTag.setAttribute(key, val);
|
|
}
|
|
}
|
|
|
|
node.insertAdjacentElement('afterend', scriptTag);
|
|
node.remove();
|
|
}
|
|
}
|