Path: blob/main/extensions/markdown-language-features/src/preview/preview.ts
3292 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import * as uri from 'vscode-uri';7import { ILogger } from '../logging';8import { MarkdownContributionProvider } from '../markdownExtensions';9import { Disposable } from '../util/dispose';10import { isMarkdownFile } from '../util/file';11import { MdLinkOpener } from '../util/openDocumentLink';12import { WebviewResourceProvider } from '../util/resources';13import { urlToUri } from '../util/url';14import { ImageInfo, MdDocumentRenderer } from './documentRenderer';15import { MarkdownPreviewConfigurationManager } from './previewConfig';16import { scrollEditorToLine, StartingScrollFragment, StartingScrollLine, StartingScrollLocation } from './scrolling';17import { getVisibleLine, LastScrollLocation, TopmostLineMonitor } from './topmostLineMonitor';18import type { FromWebviewMessage, ToWebviewMessage } from '../../types/previewMessaging';1920export class PreviewDocumentVersion {2122public readonly resource: vscode.Uri;23private readonly _version: number;2425public constructor(document: vscode.TextDocument) {26this.resource = document.uri;27this._version = document.version;28}2930public equals(other: PreviewDocumentVersion): boolean {31return this.resource.fsPath === other.resource.fsPath32&& this._version === other._version;33}34}3536interface MarkdownPreviewDelegate {37getTitle?(resource: vscode.Uri): string;38getAdditionalState(): {};39openPreviewLinkToMarkdownFile(markdownLink: vscode.Uri, fragment: string | undefined): void;40}4142class MarkdownPreview extends Disposable implements WebviewResourceProvider {4344private static readonly _unwatchedImageSchemes = new Set(['https', 'http', 'data']);4546private _disposed: boolean = false;4748private readonly _delay = 300;49private _throttleTimer: any;5051private readonly _resource: vscode.Uri;52private readonly _webviewPanel: vscode.WebviewPanel;5354private _line: number | undefined;55private readonly _scrollToFragment: string | undefined;56private _firstUpdate = true;57private _currentVersion?: PreviewDocumentVersion;58private _isScrolling = false;5960private _imageInfo: readonly ImageInfo[] = [];61private readonly _fileWatchersBySrc = new Map</* src: */ string, vscode.FileSystemWatcher>();6263private readonly _onScrollEmitter = this._register(new vscode.EventEmitter<LastScrollLocation>());64public readonly onScroll = this._onScrollEmitter.event;6566private readonly _disposeCts = this._register(new vscode.CancellationTokenSource());6768constructor(69webview: vscode.WebviewPanel,70resource: vscode.Uri,71startingScroll: StartingScrollLocation | undefined,72private readonly _delegate: MarkdownPreviewDelegate,73private readonly _contentProvider: MdDocumentRenderer,74private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,75private readonly _logger: ILogger,76private readonly _contributionProvider: MarkdownContributionProvider,77private readonly _opener: MdLinkOpener,78) {79super();8081this._webviewPanel = webview;82this._resource = resource;8384switch (startingScroll?.type) {85case 'line':86if (!isNaN(startingScroll.line!)) {87this._line = startingScroll.line;88}89break;9091case 'fragment':92this._scrollToFragment = startingScroll.fragment;93break;94}9596this._register(_contributionProvider.onContributionsChanged(() => {97setTimeout(() => this.refresh(true), 0);98}));99100this._register(vscode.workspace.onDidChangeTextDocument(event => {101if (this.isPreviewOf(event.document.uri)) {102this.refresh();103}104}));105106this._register(vscode.workspace.onDidOpenTextDocument(document => {107if (this.isPreviewOf(document.uri)) {108this.refresh();109}110}));111112const watcher = this._register(vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(resource, '*')));113this._register(watcher.onDidChange(uri => {114if (this.isPreviewOf(uri)) {115// Only use the file system event when VS Code does not already know about the file116if (!vscode.workspace.textDocuments.some(doc => doc.uri.toString() === uri.toString())) {117this.refresh();118}119}120}));121122this._register(this._webviewPanel.webview.onDidReceiveMessage((e: FromWebviewMessage.Type) => {123if (e.source !== this._resource.toString()) {124return;125}126127switch (e.type) {128case 'cacheImageSizes':129this._imageInfo = e.imageData;130break;131132case 'revealLine':133this._onDidScrollPreview(e.line);134break;135136case 'didClick':137this._onDidClickPreview(e.line);138break;139140case 'openLink':141this._onDidClickPreviewLink(e.href);142break;143144case 'showPreviewSecuritySelector':145vscode.commands.executeCommand('markdown.showPreviewSecuritySelector', e.source);146break;147148case 'previewStyleLoadError':149vscode.window.showWarningMessage(150vscode.l10n.t("Could not load 'markdown.styles': {0}", e.unloadedStyles.join(', ')));151break;152}153}));154155this.refresh();156}157158override dispose() {159this._disposeCts.cancel();160161super.dispose();162163this._disposed = true;164165clearTimeout(this._throttleTimer);166for (const entry of this._fileWatchersBySrc.values()) {167entry.dispose();168}169this._fileWatchersBySrc.clear();170}171172public get resource(): vscode.Uri {173return this._resource;174}175176public get state() {177return {178resource: this._resource.toString(),179line: this._line,180fragment: this._scrollToFragment,181...this._delegate.getAdditionalState(),182};183}184185/**186* The first call immediately refreshes the preview,187* calls happening shortly thereafter are debounced.188*/189public refresh(forceUpdate: boolean = false) {190// Schedule update if none is pending191if (!this._throttleTimer) {192if (this._firstUpdate) {193this._updatePreview(true);194} else {195this._throttleTimer = setTimeout(() => this._updatePreview(forceUpdate), this._delay);196}197}198199this._firstUpdate = false;200}201202203public isPreviewOf(resource: vscode.Uri): boolean {204return this._resource.fsPath === resource.fsPath;205}206207public postMessage(msg: ToWebviewMessage.Type) {208if (!this._disposed) {209this._webviewPanel.webview.postMessage(msg);210}211}212213public scrollTo(topLine: number) {214if (this._disposed) {215return;216}217218if (this._isScrolling) {219this._isScrolling = false;220return;221}222223this._logger.trace('MarkdownPreview', 'updateForView', { markdownFile: this._resource });224this._line = topLine;225this.postMessage({226type: 'updateView',227line: topLine,228source: this._resource.toString()229});230}231232private async _updatePreview(forceUpdate?: boolean): Promise<void> {233clearTimeout(this._throttleTimer);234this._throttleTimer = undefined;235236if (this._disposed) {237return;238}239240let document: vscode.TextDocument;241try {242document = await vscode.workspace.openTextDocument(this._resource);243} catch {244if (!this._disposed) {245await this._showFileNotFoundError();246}247return;248}249250if (this._disposed) {251return;252}253254const pendingVersion = new PreviewDocumentVersion(document);255if (!forceUpdate && this._currentVersion?.equals(pendingVersion)) {256if (this._line) {257this.scrollTo(this._line);258}259return;260}261262const shouldReloadPage = forceUpdate || !this._currentVersion || this._currentVersion.resource.toString() !== pendingVersion.resource.toString() || !this._webviewPanel.visible;263this._currentVersion = pendingVersion;264265let selectedLine: number | undefined = undefined;266for (const editor of vscode.window.visibleTextEditors) {267if (this.isPreviewOf(editor.document.uri)) {268selectedLine = editor.selection.active.line;269break;270}271}272273const content = await (shouldReloadPage274? this._contentProvider.renderDocument(document, this, this._previewConfigurations, this._line, selectedLine, this.state, this._imageInfo, this._disposeCts.token)275: this._contentProvider.renderBody(document, this));276277// Another call to `doUpdate` may have happened.278// Make sure we are still updating for the correct document279if (this._currentVersion?.equals(pendingVersion)) {280this._updateWebviewContent(content.html, shouldReloadPage);281this._updateImageWatchers(content.containingImages);282}283}284285private _onDidScrollPreview(line: number) {286this._line = line;287this._onScrollEmitter.fire({ line: this._line, uri: this._resource });288const config = this._previewConfigurations.loadAndCacheConfiguration(this._resource);289if (!config.scrollEditorWithPreview) {290return;291}292293for (const editor of vscode.window.visibleTextEditors) {294if (!this.isPreviewOf(editor.document.uri)) {295continue;296}297298this._isScrolling = true;299scrollEditorToLine(line, editor);300}301}302303private async _onDidClickPreview(line: number): Promise<void> {304// fix #82457, find currently opened but unfocused source tab305await vscode.commands.executeCommand('markdown.showSource');306307const revealLineInEditor = (editor: vscode.TextEditor) => {308const position = new vscode.Position(line, 0);309const newSelection = new vscode.Selection(position, position);310editor.selection = newSelection;311editor.revealRange(newSelection, vscode.TextEditorRevealType.InCenterIfOutsideViewport);312};313314for (const visibleEditor of vscode.window.visibleTextEditors) {315if (this.isPreviewOf(visibleEditor.document.uri)) {316const editor = await vscode.window.showTextDocument(visibleEditor.document, visibleEditor.viewColumn);317revealLineInEditor(editor);318return;319}320}321322await vscode.workspace.openTextDocument(this._resource)323.then(vscode.window.showTextDocument)324.then((editor) => {325revealLineInEditor(editor);326}, () => {327vscode.window.showErrorMessage(vscode.l10n.t('Could not open {0}', this._resource.toString()));328});329}330331private async _showFileNotFoundError() {332this._webviewPanel.webview.html = this._contentProvider.renderFileNotFoundDocument(this._resource);333}334335private _updateWebviewContent(html: string, reloadPage: boolean): void {336if (this._disposed) {337return;338}339340if (this._delegate.getTitle) {341this._webviewPanel.title = this._delegate.getTitle(this._resource);342}343this._webviewPanel.webview.options = this._getWebviewOptions();344345if (reloadPage) {346this._webviewPanel.webview.html = html;347} else {348this.postMessage({349type: 'updateContent',350content: html,351source: this._resource.toString(),352});353}354}355356private _updateImageWatchers(srcs: Set<string>) {357// Delete stale file watchers.358for (const [src, watcher] of this._fileWatchersBySrc) {359if (!srcs.has(src)) {360watcher.dispose();361this._fileWatchersBySrc.delete(src);362}363}364365// Create new file watchers.366const root = vscode.Uri.joinPath(this._resource, '../');367for (const src of srcs) {368const uri = urlToUri(src, root);369if (uri && !MarkdownPreview._unwatchedImageSchemes.has(uri.scheme) && !this._fileWatchersBySrc.has(src)) {370const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(uri, '*'));371watcher.onDidChange(() => {372this.refresh(true);373});374this._fileWatchersBySrc.set(src, watcher);375}376}377}378379private _getWebviewOptions(): vscode.WebviewOptions {380return {381enableScripts: true,382enableForms: false,383localResourceRoots: this._getLocalResourceRoots()384};385}386387private _getLocalResourceRoots(): ReadonlyArray<vscode.Uri> {388const baseRoots = Array.from(this._contributionProvider.contributions.previewResourceRoots);389390const folder = vscode.workspace.getWorkspaceFolder(this._resource);391if (folder) {392const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri);393if (workspaceRoots) {394baseRoots.push(...workspaceRoots);395}396} else {397baseRoots.push(uri.Utils.dirname(this._resource));398}399400return baseRoots;401}402403private async _onDidClickPreviewLink(href: string) {404const config = vscode.workspace.getConfiguration('markdown', this.resource);405const openLinks = config.get<string>('preview.openMarkdownLinks', 'inPreview');406if (openLinks === 'inPreview') {407const resolved = await this._opener.resolveDocumentLink(href, this.resource);408if (resolved.kind === 'file') {409try {410const doc = await vscode.workspace.openTextDocument(vscode.Uri.from(resolved.uri));411if (isMarkdownFile(doc)) {412return this._delegate.openPreviewLinkToMarkdownFile(doc.uri, resolved.fragment ? decodeURIComponent(resolved.fragment) : undefined);413}414} catch {415// Noop416}417}418}419420return this._opener.openDocumentLink(href, this.resource);421}422423//#region WebviewResourceProvider424425asWebviewUri(resource: vscode.Uri) {426return this._webviewPanel.webview.asWebviewUri(resource);427}428429get cspSource() {430return [431this._webviewPanel.webview.cspSource,432433// On web, we also need to allow loading of resources from contributed extensions434...this._contributionProvider.contributions.previewResourceRoots435.filter(root => root.scheme === 'http' || root.scheme === 'https')436.map(root => {437const dirRoot = root.path.endsWith('/') ? root : root.with({ path: root.path + '/' });438return dirRoot.toString();439}),440].join(' ');441}442443//#endregion444}445446export interface IManagedMarkdownPreview {447448readonly resource: vscode.Uri;449readonly resourceColumn: vscode.ViewColumn;450451readonly onDispose: vscode.Event<void>;452readonly onDidChangeViewState: vscode.Event<vscode.WebviewPanelOnDidChangeViewStateEvent>;453454copyImage(id: string): void;455dispose(): void;456refresh(): void;457updateConfiguration(): void;458459matchesResource(460otherResource: vscode.Uri,461otherPosition: vscode.ViewColumn | undefined,462otherLocked: boolean463): boolean;464}465466export class StaticMarkdownPreview extends Disposable implements IManagedMarkdownPreview {467468public static readonly customEditorViewType = 'vscode.markdown.preview.editor';469470public static revive(471resource: vscode.Uri,472webview: vscode.WebviewPanel,473contentProvider: MdDocumentRenderer,474previewConfigurations: MarkdownPreviewConfigurationManager,475topmostLineMonitor: TopmostLineMonitor,476logger: ILogger,477contributionProvider: MarkdownContributionProvider,478opener: MdLinkOpener,479scrollLine?: number,480): StaticMarkdownPreview {481return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, opener, scrollLine);482}483484private readonly _preview: MarkdownPreview;485486private constructor(487private readonly _webviewPanel: vscode.WebviewPanel,488resource: vscode.Uri,489contentProvider: MdDocumentRenderer,490private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,491topmostLineMonitor: TopmostLineMonitor,492logger: ILogger,493contributionProvider: MarkdownContributionProvider,494opener: MdLinkOpener,495scrollLine?: number,496) {497super();498const topScrollLocation = scrollLine ? new StartingScrollLine(scrollLine) : undefined;499this._preview = this._register(new MarkdownPreview(this._webviewPanel, resource, topScrollLocation, {500getAdditionalState: () => { return {}; },501openPreviewLinkToMarkdownFile: (markdownLink, fragment) => {502return vscode.commands.executeCommand('vscode.openWith', markdownLink.with({503fragment504}), StaticMarkdownPreview.customEditorViewType, this._webviewPanel.viewColumn);505}506}, contentProvider, _previewConfigurations, logger, contributionProvider, opener));507508this._register(this._webviewPanel.onDidDispose(() => {509this.dispose();510}));511512this._register(this._webviewPanel.onDidChangeViewState(e => {513this._onDidChangeViewState.fire(e);514}));515516this._register(this._preview.onScroll((scrollInfo) => {517topmostLineMonitor.setPreviousStaticEditorLine(scrollInfo);518}));519520this._register(topmostLineMonitor.onDidChanged(event => {521if (this._preview.isPreviewOf(event.resource)) {522this._preview.scrollTo(event.line);523}524}));525}526527copyImage(id: string) {528this._webviewPanel.reveal();529this._preview.postMessage({530type: 'copyImage',531source: this.resource.toString(),532id: id533});534}535536private readonly _onDispose = this._register(new vscode.EventEmitter<void>());537public readonly onDispose = this._onDispose.event;538539private readonly _onDidChangeViewState = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());540public readonly onDidChangeViewState = this._onDidChangeViewState.event;541542override dispose() {543this._onDispose.fire();544super.dispose();545}546547public matchesResource(548_otherResource: vscode.Uri,549_otherPosition: vscode.ViewColumn | undefined,550_otherLocked: boolean551): boolean {552return false;553}554555public refresh() {556this._preview.refresh(true);557}558559public updateConfiguration() {560if (this._previewConfigurations.hasConfigurationChanged(this._preview.resource)) {561this.refresh();562}563}564565public get resource() {566return this._preview.resource;567}568569public get resourceColumn() {570return this._webviewPanel.viewColumn || vscode.ViewColumn.One;571}572}573574interface DynamicPreviewInput {575readonly resource: vscode.Uri;576readonly resourceColumn: vscode.ViewColumn;577readonly locked: boolean;578readonly line?: number;579}580581export class DynamicMarkdownPreview extends Disposable implements IManagedMarkdownPreview {582583public static readonly viewType = 'markdown.preview';584585private readonly _resourceColumn: vscode.ViewColumn;586private _locked: boolean;587588private readonly _webviewPanel: vscode.WebviewPanel;589private _preview: MarkdownPreview;590591public static revive(592input: DynamicPreviewInput,593webview: vscode.WebviewPanel,594contentProvider: MdDocumentRenderer,595previewConfigurations: MarkdownPreviewConfigurationManager,596logger: ILogger,597topmostLineMonitor: TopmostLineMonitor,598contributionProvider: MarkdownContributionProvider,599opener: MdLinkOpener,600): DynamicMarkdownPreview {601webview.iconPath = contentProvider.iconPath;602603return new DynamicMarkdownPreview(webview, input,604contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, opener);605}606607public static create(608input: DynamicPreviewInput,609previewColumn: vscode.ViewColumn,610contentProvider: MdDocumentRenderer,611previewConfigurations: MarkdownPreviewConfigurationManager,612logger: ILogger,613topmostLineMonitor: TopmostLineMonitor,614contributionProvider: MarkdownContributionProvider,615opener: MdLinkOpener,616): DynamicMarkdownPreview {617const webview = vscode.window.createWebviewPanel(618DynamicMarkdownPreview.viewType,619DynamicMarkdownPreview._getPreviewTitle(input.resource, input.locked),620previewColumn, { enableFindWidget: true, });621622webview.iconPath = contentProvider.iconPath;623624return new DynamicMarkdownPreview(webview, input,625contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, opener);626}627628private constructor(629webview: vscode.WebviewPanel,630input: DynamicPreviewInput,631private readonly _contentProvider: MdDocumentRenderer,632private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,633private readonly _logger: ILogger,634private readonly _topmostLineMonitor: TopmostLineMonitor,635private readonly _contributionProvider: MarkdownContributionProvider,636private readonly _opener: MdLinkOpener,637) {638super();639640this._webviewPanel = webview;641642this._resourceColumn = input.resourceColumn;643this._locked = input.locked;644645this._preview = this._createPreview(input.resource, typeof input.line === 'number' ? new StartingScrollLine(input.line) : undefined);646647this._register(webview.onDidDispose(() => { this.dispose(); }));648649this._register(this._webviewPanel.onDidChangeViewState(e => {650this._onDidChangeViewStateEmitter.fire(e);651}));652653this._register(this._topmostLineMonitor.onDidChanged(event => {654if (this._preview.isPreviewOf(event.resource)) {655this._preview.scrollTo(event.line);656}657}));658659this._register(vscode.window.onDidChangeTextEditorSelection(event => {660if (this._preview.isPreviewOf(event.textEditor.document.uri)) {661this._preview.postMessage({662type: 'onDidChangeTextEditorSelection',663line: event.selections[0].active.line,664source: this._preview.resource.toString()665});666}667}));668669this._register(vscode.window.onDidChangeActiveTextEditor(editor => {670// Only allow previewing normal text editors which have a viewColumn: See #101514671if (typeof editor?.viewColumn === 'undefined') {672return;673}674675if (isMarkdownFile(editor.document) && !this._locked && !this._preview.isPreviewOf(editor.document.uri)) {676const line = getVisibleLine(editor);677this.update(editor.document.uri, line ? new StartingScrollLine(line) : undefined);678}679}));680}681682copyImage(id: string) {683this._webviewPanel.reveal();684this._preview.postMessage({685type: 'copyImage',686source: this.resource.toString(),687id: id688});689}690691private readonly _onDisposeEmitter = this._register(new vscode.EventEmitter<void>());692public readonly onDispose = this._onDisposeEmitter.event;693694private readonly _onDidChangeViewStateEmitter = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());695public readonly onDidChangeViewState = this._onDidChangeViewStateEmitter.event;696697override dispose() {698this._preview.dispose();699this._webviewPanel.dispose();700701this._onDisposeEmitter.fire();702this._onDisposeEmitter.dispose();703super.dispose();704}705706public get resource() {707return this._preview.resource;708}709710public get resourceColumn() {711return this._resourceColumn;712}713714public reveal(viewColumn: vscode.ViewColumn) {715this._webviewPanel.reveal(viewColumn);716}717718public refresh() {719this._preview.refresh(true);720}721722public updateConfiguration() {723if (this._previewConfigurations.hasConfigurationChanged(this._preview.resource)) {724this.refresh();725}726}727728public update(newResource: vscode.Uri, scrollLocation?: StartingScrollLocation) {729if (this._preview.isPreviewOf(newResource)) {730switch (scrollLocation?.type) {731case 'line':732this._preview.scrollTo(scrollLocation.line);733return;734735case 'fragment':736// Workaround. For fragments, just reload the entire preview737break;738739default:740return;741}742}743744this._preview.dispose();745this._preview = this._createPreview(newResource, scrollLocation);746}747748public toggleLock() {749this._locked = !this._locked;750this._webviewPanel.title = DynamicMarkdownPreview._getPreviewTitle(this._preview.resource, this._locked);751}752753private static _getPreviewTitle(resource: vscode.Uri, locked: boolean): string {754const resourceLabel = uri.Utils.basename(resource);755return locked756? vscode.l10n.t('[Preview] {0}', resourceLabel)757: vscode.l10n.t('Preview {0}', resourceLabel);758}759760public get position(): vscode.ViewColumn | undefined {761return this._webviewPanel.viewColumn;762}763764public matchesResource(765otherResource: vscode.Uri,766otherPosition: vscode.ViewColumn | undefined,767otherLocked: boolean768): boolean {769if (this.position !== otherPosition) {770return false;771}772773if (this._locked) {774return otherLocked && this._preview.isPreviewOf(otherResource);775} else {776return !otherLocked;777}778}779780public matches(otherPreview: DynamicMarkdownPreview): boolean {781return this.matchesResource(otherPreview._preview.resource, otherPreview.position, otherPreview._locked);782}783784private _createPreview(resource: vscode.Uri, startingScroll?: StartingScrollLocation): MarkdownPreview {785return new MarkdownPreview(this._webviewPanel, resource, startingScroll, {786getTitle: (resource) => DynamicMarkdownPreview._getPreviewTitle(resource, this._locked),787getAdditionalState: () => {788return {789resourceColumn: this.resourceColumn,790locked: this._locked,791};792},793openPreviewLinkToMarkdownFile: (link: vscode.Uri, fragment?: string) => {794this.update(link, fragment ? new StartingScrollFragment(fragment) : undefined);795}796},797this._contentProvider,798this._previewConfigurations,799this._logger,800this._contributionProvider,801this._opener);802}803}804805806