Skip to content

Commit 5c9093d

Browse files
authored
Merge pull request #315119 from microsoft/dev/mjbvz/flying-canid
Use a broadcast channel for md preview diff scroll sync
2 parents d162706 + 962c0a0 commit 5c9093d

8 files changed

Lines changed: 136 additions & 88 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import type { DiffScrollSyncData } from '../types/previewMessaging';
7+
8+
export class DiffScrollSyncManager {
9+
10+
readonly #previewId = Math.random().toString(36).slice(2, 10);
11+
readonly #onIncomingScroll: (line: number) => void;
12+
13+
#syncData: DiffScrollSyncData;
14+
readonly #channel: BroadcastChannel;
15+
#ignoreIncomingScrollUntil = 0;
16+
17+
constructor(sync: DiffScrollSyncData, onIncomingScroll: (line: number) => void) {
18+
this.#onIncomingScroll = onIncomingScroll;
19+
this.#syncData = sync;
20+
this.#channel = new BroadcastChannel(sync.channelName);
21+
this.#channel.onmessage = (event) => {
22+
const { line, sender } = event.data;
23+
if (sender === this.#previewId) {
24+
return;
25+
}
26+
if (typeof line !== 'number' || isNaN(line)) {
27+
return;
28+
}
29+
30+
const mappedLine = translateLineWithMappings(line, this.#syncData.lineMappings);
31+
this.#ignoreIncomingScrollUntil = Date.now() + 100;
32+
this.#onIncomingScroll(mappedLine);
33+
};
34+
}
35+
36+
update(sync: DiffScrollSyncData): void {
37+
this.#syncData = sync;
38+
}
39+
40+
broadcastScroll(line: number): void {
41+
if (!this.#channel || Date.now() < this.#ignoreIncomingScrollUntil) {
42+
return;
43+
}
44+
this.#channel.postMessage({ line, sender: this.#previewId });
45+
}
46+
}
47+
48+
function translateLineWithMappings(line: number, mappings: readonly number[] | undefined): number {
49+
if (!mappings?.length) {
50+
return line;
51+
}
52+
const sourceLine = Math.floor(line);
53+
const progress = line - sourceLine;
54+
const mappedLine = mappings[sourceLine] ?? line;
55+
if (progress <= 0) {
56+
return Math.max(0, mappedLine);
57+
}
58+
const nextMappedLine = mappings[sourceLine + 1];
59+
if (typeof nextMappedLine !== 'number') {
60+
return Math.max(0, mappedLine + progress);
61+
}
62+
return Math.max(0, mappedLine + ((nextMappedLine - mappedLine) * progress));
63+
}

‎extensions/markdown-language-features/preview-src/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import throttle = require('lodash.throttle');
1212
import morphdom from 'morphdom';
1313
import type { MarkdownPreviewLineChanges, ToWebviewMessage } from '../types/previewMessaging';
1414
import { isOfScheme, Schemes } from '../src/util/schemes';
15+
import { DiffScrollSyncManager } from './diffScrollSync';
1516

1617
let scrollDisabledCount = 0;
1718
let scrollDisabledTimer: number | undefined;
@@ -25,6 +26,18 @@ let lineChanges = settings.settings.lineChanges;
2526

2627
const vscode = acquireVsCodeApi();
2728

29+
const onDiffScroll = (mappedLine: number) => {
30+
scrollDisabledCount = 1;
31+
if (scrollDisabledTimer) {
32+
clearTimeout(scrollDisabledTimer);
33+
}
34+
scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 100);
35+
doAfterImagesLoaded(() => scrollToRevealSourceLine(mappedLine, documentVersion, settings));
36+
};
37+
const diffScrollSyncManager = settings.settings.diffScrollSync
38+
? new DiffScrollSyncManager(settings.settings.diffScrollSync, onDiffScroll)
39+
: undefined;
40+
2841
interface State {
2942
scrollProgress?: number;
3043
resource?: string;
@@ -248,6 +261,9 @@ window.addEventListener('message', async event => {
248261

249262
case 'updateContent': {
250263
lineChanges = data.lineChanges;
264+
if (data.diffScrollSync) {
265+
diffScrollSyncManager?.update(data.diffScrollSync);
266+
}
251267
const root = document.querySelector('.markdown-body')!;
252268

253269
const parser = new DOMParser();
@@ -468,6 +484,7 @@ window.addEventListener('scroll', throttle(() => {
468484
state.line = line;
469485
vscode.setState(state);
470486
messaging.postMessage('revealLine', { line });
487+
diffScrollSyncManager?.broadcastScroll(line);
471488
}
472489
}, 50));
473490

‎extensions/markdown-language-features/preview-src/settings.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import type { MarkdownPreviewLineChanges } from '../types/previewMessaging';
6+
import type { DiffScrollSyncData, MarkdownPreviewLineChanges } from '../types/previewMessaging';
77

88
export interface PreviewSettings {
99
readonly source: string;
1010
readonly line?: number;
1111
readonly fragment?: string;
1212
readonly selectedLine?: number;
1313
readonly lineChanges?: MarkdownPreviewLineChanges;
14+
readonly diffScrollSync?: DiffScrollSyncData;
1415

1516
readonly scrollPreviewWithEditor?: boolean;
1617
readonly scrollEditorWithPreview: boolean;

‎extensions/markdown-language-features/src/preview/documentRenderer.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { WebviewResourceProvider } from '../util/resources';
1313
import { generateUuid } from '../util/uuid';
1414
import { MarkdownPreviewConfiguration, MarkdownPreviewConfigurationManager } from './previewConfig';
1515
import { ContentSecurityPolicyArbiter, MarkdownPreviewSecurityLevel } from './security';
16-
import type { MarkdownPreviewInnerChange, MarkdownPreviewLineChanges } from '../../types/previewMessaging';
16+
import type { DiffScrollSyncData, MarkdownPreviewInnerChange, MarkdownPreviewLineChanges } from '../../types/previewMessaging';
1717

1818

1919
/**
@@ -78,6 +78,7 @@ export class MdDocumentRenderer {
7878
state: any | undefined,
7979
imageInfo: readonly ImageInfo[],
8080
lineChanges: MarkdownPreviewLineChanges | undefined,
81+
diffScrollSync: DiffScrollSyncData | undefined,
8182
token: vscode.CancellationToken
8283
): Promise<MarkdownContentProviderOutput> {
8384
const sourceUri = markdownDocument.uri;
@@ -88,6 +89,7 @@ export class MdDocumentRenderer {
8889
line: initialLine,
8990
selectedLine,
9091
lineChanges,
92+
diffScrollSync,
9193
scrollPreviewWithEditor: config.scrollPreviewWithEditor,
9294
scrollEditorWithPreview: config.scrollEditorWithPreview,
9395
doubleClickToSwitchToEditor: config.doubleClickToSwitchToEditor,

‎extensions/markdown-language-features/src/preview/lineDiff.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ export class MarkdownPreviewLineDiffProvider {
5959
return translateLine(line, (await this.#getLineChanges()).modifiedToOriginal, this.#originalDocument.lineCount);
6060
}
6161

62+
public async getOriginalToModifiedMappings(): Promise<readonly number[]> {
63+
return (await this.#getLineChanges()).originalToModified;
64+
}
65+
66+
public async getModifiedToOriginalMappings(): Promise<readonly number[]> {
67+
return (await this.#getLineChanges()).modifiedToOriginal;
68+
}
69+
6270
#getLineChanges(): Promise<LineChanges> {
6371
if (!this.#cachedLineChanges || this.#cachedOriginalVersion !== this.#originalDocument.version || this.#cachedModifiedVersion !== this.#modifiedDocument.version) {
6472
this.#cachedOriginalVersion = this.#originalDocument.version;

‎extensions/markdown-language-features/src/preview/preview.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { ImageInfo, MdDocumentRenderer } from './documentRenderer';
1616
import { MarkdownPreviewConfigurationManager } from './previewConfig';
1717
import { scrollEditorToLine, StartingScrollFragment, StartingScrollLine, StartingScrollLocation } from './scrolling';
1818
import { getVisibleLine, LastScrollLocation, TopmostLineMonitor } from './topmostLineMonitor';
19-
import type { FromWebviewMessage, MarkdownPreviewLineChanges, ToWebviewMessage } from '../../types/previewMessaging';
19+
import type { DiffScrollSyncData, FromWebviewMessage, MarkdownPreviewLineChanges, ToWebviewMessage } from '../../types/previewMessaging';
2020

2121
export class PreviewDocumentVersion {
2222

@@ -38,6 +38,7 @@ interface MarkdownPreviewDelegate {
3838
getTitle?(resource: vscode.Uri): string;
3939
getAdditionalState(): {};
4040
getLineChanges?(): MarkdownPreviewLineChanges | Promise<MarkdownPreviewLineChanges | undefined> | undefined;
41+
getDiffScrollSync?(): DiffScrollSyncData | Promise<DiffScrollSyncData | undefined> | undefined;
4142
openPreviewLinkToMarkdownFile(markdownLink: vscode.Uri, fragment: string | undefined): void;
4243
}
4344

@@ -294,14 +295,15 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
294295
}
295296

296297
const lineChanges = await this.#delegate.getLineChanges?.();
298+
const diffScrollSync = await this.#delegate.getDiffScrollSync?.();
297299
const content = await (shouldReloadPage
298-
? this.#contentProvider.renderDocument(document, this, this.#previewConfigurations, this.#line, selectedLine, this.state, this.#imageInfo, lineChanges, this.#disposeCts.token)
300+
? this.#contentProvider.renderDocument(document, this, this.#previewConfigurations, this.#line, selectedLine, this.state, this.#imageInfo, lineChanges, diffScrollSync, this.#disposeCts.token)
299301
: this.#contentProvider.renderBody(document, this, lineChanges));
300302

301303
// Another call to `doUpdate` may have happened.
302304
// Make sure we are still updating for the correct document
303305
if (this.#currentVersion?.equals(pendingVersion)) {
304-
this.#updateWebviewContent(content.html, shouldReloadPage, lineChanges);
306+
this.#updateWebviewContent(content.html, shouldReloadPage, lineChanges, diffScrollSync);
305307
this.#updateImageWatchers(content.containingImages);
306308
}
307309
}
@@ -362,7 +364,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
362364
this.#webviewPanel.webview.html = this.#contentProvider.renderFileNotFoundDocument(this.#resource);
363365
}
364366

365-
#updateWebviewContent(html: string, reloadPage: boolean, lineChanges: MarkdownPreviewLineChanges | undefined): void {
367+
#updateWebviewContent(html: string, reloadPage: boolean, lineChanges: MarkdownPreviewLineChanges | undefined, diffScrollSync: DiffScrollSyncData | undefined): void {
366368
if (this.#disposed) {
367369
return;
368370
}
@@ -379,6 +381,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
379381
type: 'updateContent',
380382
content: html,
381383
lineChanges,
384+
diffScrollSync,
382385
source: this.#resource.toString(),
383386
});
384387
}
@@ -509,10 +512,11 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow
509512
opener: MdLinkOpener,
510513
scrollLine?: number,
511514
getLineChanges?: () => MarkdownPreviewLineChanges | Promise<MarkdownPreviewLineChanges | undefined> | undefined,
515+
getDiffScrollSync?: () => DiffScrollSyncData | Promise<DiffScrollSyncData | undefined> | undefined,
512516
): StaticMarkdownPreview {
513517
webview.iconPath = contentProvider.iconPath;
514518

515-
return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, opener, scrollLine, getLineChanges);
519+
return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, opener, scrollLine, getLineChanges, getDiffScrollSync);
516520
}
517521

518522
readonly #preview: MarkdownPreview;
@@ -531,6 +535,7 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow
531535
opener: MdLinkOpener,
532536
scrollLine?: number,
533537
getLineChanges?: () => MarkdownPreviewLineChanges | Promise<MarkdownPreviewLineChanges | undefined> | undefined,
538+
getDiffScrollSync?: () => DiffScrollSyncData | Promise<DiffScrollSyncData | undefined> | undefined,
534539
) {
535540
super();
536541

@@ -541,6 +546,7 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow
541546
this.#preview = this._register(new MarkdownPreview(this.#webviewPanel, resource, topScrollLocation, {
542547
getAdditionalState: () => { return {}; },
543548
getLineChanges,
549+
getDiffScrollSync,
544550
openPreviewLinkToMarkdownFile: (markdownLink, fragment) => {
545551
return vscode.commands.executeCommand('vscode.openWith', markdownLink.with({
546552
fragment

‎extensions/markdown-language-features/src/preview/previewManager.ts‎

Lines changed: 22 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import { MarkdownContributionProvider } from '../markdownExtensions';
99
import { Disposable, disposeAll } from '../util/dispose';
1010
import { isMarkdownFile } from '../util/file';
1111
import { MdLinkOpener } from '../util/openDocumentLink';
12+
import { generateUuid } from '../util/uuid';
1213
import { MdDocumentRenderer } from './documentRenderer';
1314
import { MarkdownPreviewLineDiffProvider } from './lineDiff';
1415
import { DynamicMarkdownPreview, IManagedMarkdownPreview, StaticMarkdownPreview } from './preview';
1516
import { MarkdownPreviewConfigurationManager } from './previewConfig';
1617
import { scrollEditorToLine, StartingScrollFragment, StartingScrollLine, StartingScrollLocation } from './scrolling';
1718
import { getVisibleLine, TopmostLineMonitor } from './topmostLineMonitor';
18-
import type { MarkdownPreviewLineChanges } from '../../types/previewMessaging';
19+
import type { DiffScrollSyncData, MarkdownPreviewLineChanges } from '../../types/previewMessaging';
1920

2021

2122
export interface DynamicPreviewSettings {
@@ -267,17 +268,32 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview
267268
webviewPanels: vscode.CustomEditorDiffWebviewPanels
268269
): Promise<void> {
269270
const lineDiffProvider = new MarkdownPreviewLineDiffProvider(documents.original, documents.modified);
270-
const originalPreview = this.#resolveCustomTextEditor(documents.original, webviewPanels.original, () => lineDiffProvider.getOriginalLineChanges());
271-
const modifiedPreview = this.#resolveCustomTextEditor(documents.modified, webviewPanels.modified, () => lineDiffProvider.getModifiedLineChanges());
271+
const channelName = `md-diff-scroll-${generateUuid()}`;
272+
const originalPreview = this.#resolveCustomTextEditor(
273+
documents.original, webviewPanels.original,
274+
() => lineDiffProvider.getOriginalLineChanges(),
275+
async () => ({
276+
channelName,
277+
role: 'original' as const,
278+
lineMappings: [...await lineDiffProvider.getModifiedToOriginalMappings()],
279+
}));
280+
const modifiedPreview = this.#resolveCustomTextEditor(
281+
documents.modified, webviewPanels.modified,
282+
() => lineDiffProvider.getModifiedLineChanges(),
283+
async () => ({
284+
channelName,
285+
role: 'modified' as const,
286+
lineMappings: [...await lineDiffProvider.getOriginalToModifiedMappings()],
287+
}));
272288
this.#refreshPreviewWhenDocumentChanges(originalPreview, documents.modified);
273289
this.#refreshPreviewWhenDocumentChanges(modifiedPreview, documents.original);
274-
this.#syncSideBySidePreviewScrolling(originalPreview, modifiedPreview, lineDiffProvider);
275290
}
276291

277292
#resolveCustomTextEditor(
278293
document: vscode.TextDocument,
279294
webview: vscode.WebviewPanel,
280295
getLineChanges?: () => MarkdownPreviewLineChanges | Promise<MarkdownPreviewLineChanges | undefined> | undefined,
296+
getDiffScrollSync?: () => DiffScrollSyncData | Promise<DiffScrollSyncData | undefined> | undefined,
281297
): StaticMarkdownPreview {
282298
const lineNumber = this.#topmostLineMonitor.getPreviousTextEditorLineByUri(document.uri);
283299
const preview = StaticMarkdownPreview.revive(
@@ -290,7 +306,8 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview
290306
this.#contributions,
291307
this.#opener,
292308
lineNumber,
293-
getLineChanges
309+
getLineChanges,
310+
getDiffScrollSync
294311
);
295312
this.#registerStaticPreview(preview);
296313
this.#activePreview = preview;
@@ -306,82 +323,6 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview
306323
preview.onDispose(() => listener.dispose());
307324
}
308325

309-
#syncSideBySidePreviewScrolling(originalPreview: StaticMarkdownPreview, modifiedPreview: StaticMarkdownPreview, lineDiffProvider: MarkdownPreviewLineDiffProvider): void {
310-
let disposed = false;
311-
let ignoreOriginalScrollUntil = 0;
312-
let ignoreModifiedScrollUntil = 0;
313-
let originalToModifiedRequest = 0;
314-
let modifiedToOriginalRequest = 0;
315-
316-
const syncScroll = async (
317-
line: number,
318-
targetPreview: StaticMarkdownPreview,
319-
translateLine: (line: number) => Promise<number>,
320-
request: number,
321-
shouldApply: (request: number) => boolean,
322-
ignoreTargetScroll: () => void,
323-
) => {
324-
if (disposed) {
325-
return;
326-
}
327-
328-
let targetLine: number;
329-
try {
330-
targetLine = await translateLine(line);
331-
} catch {
332-
targetLine = line;
333-
}
334-
335-
if (!disposed && shouldApply(request)) {
336-
ignoreTargetScroll();
337-
targetPreview.scrollTo(targetLine);
338-
}
339-
};
340-
341-
const disposables = [
342-
originalPreview.onScroll(({ line }) => {
343-
if (Date.now() < ignoreOriginalScrollUntil) {
344-
return;
345-
}
346-
const request = ++originalToModifiedRequest;
347-
void syncScroll(
348-
line,
349-
modifiedPreview,
350-
value => lineDiffProvider.translateOriginalLineToModified(value),
351-
request,
352-
value => value === originalToModifiedRequest,
353-
() => { ignoreModifiedScrollUntil = Date.now() + 100; },
354-
);
355-
}),
356-
modifiedPreview.onScroll(({ line }) => {
357-
if (Date.now() < ignoreModifiedScrollUntil) {
358-
return;
359-
}
360-
const request = ++modifiedToOriginalRequest;
361-
void syncScroll(
362-
line,
363-
originalPreview,
364-
value => lineDiffProvider.translateModifiedLineToOriginal(value),
365-
request,
366-
value => value === modifiedToOriginalRequest,
367-
() => { ignoreOriginalScrollUntil = Date.now() + 100; },
368-
);
369-
}),
370-
];
371-
372-
const dispose = () => {
373-
if (disposed) {
374-
return;
375-
}
376-
disposed = true;
377-
++originalToModifiedRequest;
378-
++modifiedToOriginalRequest;
379-
disposeAll(disposables);
380-
};
381-
originalPreview.onDispose(dispose);
382-
modifiedPreview.onDispose(dispose);
383-
}
384-
385326
#createNewDynamicPreview(
386327
resource: vscode.Uri,
387328
previewSettings: DynamicPreviewSettings,

0 commit comments

Comments
 (0)