Path: blob/main/extensions/markdown-language-features/src/preview/preview.ts
5240 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}));111112if (vscode.workspace.fs.isWritableFileSystem(resource.scheme)) {113const watcher = this._register(vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(resource, '*')));114this._register(watcher.onDidChange(uri => {115if (this.isPreviewOf(uri)) {116// Only use the file system event when VS Code does not already know about the file117if (!vscode.workspace.textDocuments.some(doc => doc.uri.toString() === uri.toString())) {118this.refresh();119}120}121}));122}123124this._register(this._webviewPanel.webview.onDidReceiveMessage((e: FromWebviewMessage.Type) => {125if (e.source !== this._resource.toString()) {126return;127}128129switch (e.type) {130case 'cacheImageSizes':131this._imageInfo = e.imageData;132break;133134case 'revealLine':135this._onDidScrollPreview(e.line);136break;137138case 'didClick':139this._onDidClickPreview(e.line);140break;141142case 'openLink':143this._onDidClickPreviewLink(e.href);144break;145146case 'showPreviewSecuritySelector':147vscode.commands.executeCommand('markdown.showPreviewSecuritySelector', e.source);148break;149150case 'previewStyleLoadError':151vscode.window.showWarningMessage(152vscode.l10n.t("Could not load 'markdown.styles': {0}", e.unloadedStyles.join(', ')));153break;154}155}));156157this.refresh();158}159160override dispose() {161this._disposeCts.cancel();162163super.dispose();164165this._disposed = true;166167clearTimeout(this._throttleTimer);168for (const entry of this._fileWatchersBySrc.values()) {169entry.dispose();170}171this._fileWatchersBySrc.clear();172}173174public get resource(): vscode.Uri {175return this._resource;176}177178public get state() {179return {180resource: this._resource.toString(),181line: this._line,182fragment: this._scrollToFragment,183...this._delegate.getAdditionalState(),184};185}186187/**188* The first call immediately refreshes the preview,189* calls happening shortly thereafter are debounced.190*/191public refresh(forceUpdate: boolean = false) {192// Schedule update if none is pending193if (!this._throttleTimer) {194if (this._firstUpdate) {195this._updatePreview(true);196} else {197this._throttleTimer = setTimeout(() => this._updatePreview(forceUpdate), this._delay);198}199}200201this._firstUpdate = false;202}203204205public isPreviewOf(resource: vscode.Uri): boolean {206return this._resource.fsPath === resource.fsPath;207}208209public postMessage(msg: ToWebviewMessage.Type) {210if (!this._disposed) {211this._webviewPanel.webview.postMessage(msg);212}213}214215public scrollTo(topLine: number) {216if (this._disposed) {217return;218}219220if (this._isScrolling) {221this._isScrolling = false;222return;223}224225this._logger.trace('MarkdownPreview', 'updateForView', { markdownFile: this._resource });226this._line = topLine;227this.postMessage({228type: 'updateView',229line: topLine,230source: this._resource.toString()231});232}233234private async _updatePreview(forceUpdate?: boolean): Promise<void> {235clearTimeout(this._throttleTimer);236this._throttleTimer = undefined;237238if (this._disposed) {239return;240}241242let document: vscode.TextDocument;243try {244document = await vscode.workspace.openTextDocument(this._resource);245} catch {246if (!this._disposed) {247await this._showFileNotFoundError();248}249return;250}251252if (this._disposed) {253return;254}255256const pendingVersion = new PreviewDocumentVersion(document);257if (!forceUpdate && this._currentVersion?.equals(pendingVersion)) {258if (this._line) {259this.scrollTo(this._line);260}261return;262}263264const shouldReloadPage = forceUpdate || !this._currentVersion || this._currentVersion.resource.toString() !== pendingVersion.resource.toString() || !this._webviewPanel.visible;265this._currentVersion = pendingVersion;266267let selectedLine: number | undefined = undefined;268for (const editor of vscode.window.visibleTextEditors) {269if (this.isPreviewOf(editor.document.uri)) {270selectedLine = editor.selection.active.line;271break;272}273}274275const content = await (shouldReloadPage276? this._contentProvider.renderDocument(document, this, this._previewConfigurations, this._line, selectedLine, this.state, this._imageInfo, this._disposeCts.token)277: this._contentProvider.renderBody(document, this));278279// Another call to `doUpdate` may have happened.280// Make sure we are still updating for the correct document281if (this._currentVersion?.equals(pendingVersion)) {282this._updateWebviewContent(content.html, shouldReloadPage);283this._updateImageWatchers(content.containingImages);284}285}286287private _onDidScrollPreview(line: number) {288this._line = line;289this._onScrollEmitter.fire({ line: this._line, uri: this._resource });290const config = this._previewConfigurations.loadAndCacheConfiguration(this._resource);291if (!config.scrollEditorWithPreview) {292return;293}294295for (const editor of vscode.window.visibleTextEditors) {296if (!this.isPreviewOf(editor.document.uri)) {297continue;298}299300this._isScrolling = true;301scrollEditorToLine(line, editor);302}303}304305private async _onDidClickPreview(line: number): Promise<void> {306// fix #82457, find currently opened but unfocused source tab307await vscode.commands.executeCommand('markdown.showSource');308309const revealLineInEditor = (editor: vscode.TextEditor) => {310const position = new vscode.Position(line, 0);311const newSelection = new vscode.Selection(position, position);312editor.selection = newSelection;313editor.revealRange(newSelection, vscode.TextEditorRevealType.InCenterIfOutsideViewport);314};315316for (const visibleEditor of vscode.window.visibleTextEditors) {317if (this.isPreviewOf(visibleEditor.document.uri)) {318const editor = await vscode.window.showTextDocument(visibleEditor.document, visibleEditor.viewColumn);319revealLineInEditor(editor);320return;321}322}323324await vscode.workspace.openTextDocument(this._resource)325.then(vscode.window.showTextDocument)326.then((editor) => {327revealLineInEditor(editor);328}, () => {329vscode.window.showErrorMessage(vscode.l10n.t('Could not open {0}', this._resource.toString()));330});331}332333private async _showFileNotFoundError() {334this._webviewPanel.webview.html = this._contentProvider.renderFileNotFoundDocument(this._resource);335}336337private _updateWebviewContent(html: string, reloadPage: boolean): void {338if (this._disposed) {339return;340}341342if (this._delegate.getTitle) {343this._webviewPanel.title = this._delegate.getTitle(this._resource);344}345this._webviewPanel.webview.options = this._getWebviewOptions();346347if (reloadPage) {348this._webviewPanel.webview.html = html;349} else {350this.postMessage({351type: 'updateContent',352content: html,353source: this._resource.toString(),354});355}356}357358private _updateImageWatchers(srcs: Set<string>) {359// Delete stale file watchers.360for (const [src, watcher] of this._fileWatchersBySrc) {361if (!srcs.has(src)) {362watcher.dispose();363this._fileWatchersBySrc.delete(src);364}365}366367// Create new file watchers.368const root = vscode.Uri.joinPath(this._resource, '../');369for (const src of srcs) {370const uri = urlToUri(src, root);371if (uri && !MarkdownPreview._unwatchedImageSchemes.has(uri.scheme) && !this._fileWatchersBySrc.has(src)) {372const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(uri, '*'));373watcher.onDidChange(() => {374this.refresh(true);375});376this._fileWatchersBySrc.set(src, watcher);377}378}379}380381private _getWebviewOptions(): vscode.WebviewOptions {382return {383enableScripts: true,384enableForms: false,385localResourceRoots: this._getLocalResourceRoots()386};387}388389private _getLocalResourceRoots(): ReadonlyArray<vscode.Uri> {390const baseRoots = Array.from(this._contributionProvider.contributions.previewResourceRoots);391392const folder = vscode.workspace.getWorkspaceFolder(this._resource);393if (folder) {394const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri);395if (workspaceRoots) {396baseRoots.push(...workspaceRoots);397}398} else {399baseRoots.push(uri.Utils.dirname(this._resource));400}401402return baseRoots;403}404405private async _onDidClickPreviewLink(href: string) {406const config = vscode.workspace.getConfiguration('markdown', this.resource);407const openLinks = config.get<string>('preview.openMarkdownLinks', 'inPreview');408if (openLinks === 'inPreview') {409const resolved = await this._opener.resolveDocumentLink(href, this.resource);410if (resolved.kind === 'file') {411try {412const doc = await vscode.workspace.openTextDocument(vscode.Uri.from(resolved.uri));413if (isMarkdownFile(doc)) {414return this._delegate.openPreviewLinkToMarkdownFile(doc.uri, resolved.fragment ? decodeURIComponent(resolved.fragment) : undefined);415}416} catch {417// Noop418}419}420}421422return this._opener.openDocumentLink(href, this.resource);423}424425//#region WebviewResourceProvider426427asWebviewUri(resource: vscode.Uri) {428return this._webviewPanel.webview.asWebviewUri(resource);429}430431get cspSource() {432return [433this._webviewPanel.webview.cspSource,434435// On web, we also need to allow loading of resources from contributed extensions436...this._contributionProvider.contributions.previewResourceRoots437.filter(root => root.scheme === 'http' || root.scheme === 'https')438.map(root => {439const dirRoot = root.path.endsWith('/') ? root : root.with({ path: root.path + '/' });440return dirRoot.toString();441}),442].join(' ');443}444445//#endregion446}447448export interface IManagedMarkdownPreview {449450readonly resource: vscode.Uri;451readonly resourceColumn: vscode.ViewColumn;452453readonly onDispose: vscode.Event<void>;454readonly onDidChangeViewState: vscode.Event<vscode.WebviewPanelOnDidChangeViewStateEvent>;455456copyImage(id: string): void;457dispose(): void;458refresh(): void;459updateConfiguration(): void;460461matchesResource(462otherResource: vscode.Uri,463otherPosition: vscode.ViewColumn | undefined,464otherLocked: boolean465): boolean;466}467468export class StaticMarkdownPreview extends Disposable implements IManagedMarkdownPreview {469470public static readonly customEditorViewType = 'vscode.markdown.preview.editor';471472public static revive(473resource: vscode.Uri,474webview: vscode.WebviewPanel,475contentProvider: MdDocumentRenderer,476previewConfigurations: MarkdownPreviewConfigurationManager,477topmostLineMonitor: TopmostLineMonitor,478logger: ILogger,479contributionProvider: MarkdownContributionProvider,480opener: MdLinkOpener,481scrollLine?: number,482): StaticMarkdownPreview {483return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, opener, scrollLine);484}485486private readonly _preview: MarkdownPreview;487488private constructor(489private readonly _webviewPanel: vscode.WebviewPanel,490resource: vscode.Uri,491contentProvider: MdDocumentRenderer,492private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,493topmostLineMonitor: TopmostLineMonitor,494logger: ILogger,495contributionProvider: MarkdownContributionProvider,496opener: MdLinkOpener,497scrollLine?: number,498) {499super();500const topScrollLocation = scrollLine ? new StartingScrollLine(scrollLine) : undefined;501this._preview = this._register(new MarkdownPreview(this._webviewPanel, resource, topScrollLocation, {502getAdditionalState: () => { return {}; },503openPreviewLinkToMarkdownFile: (markdownLink, fragment) => {504return vscode.commands.executeCommand('vscode.openWith', markdownLink.with({505fragment506}), StaticMarkdownPreview.customEditorViewType, this._webviewPanel.viewColumn);507}508}, contentProvider, _previewConfigurations, logger, contributionProvider, opener));509510this._register(this._webviewPanel.onDidDispose(() => {511this.dispose();512}));513514this._register(this._webviewPanel.onDidChangeViewState(e => {515this._onDidChangeViewState.fire(e);516}));517518this._register(this._preview.onScroll((scrollInfo) => {519topmostLineMonitor.setPreviousStaticEditorLine(scrollInfo);520}));521522this._register(topmostLineMonitor.onDidChanged(event => {523if (this._preview.isPreviewOf(event.resource)) {524this._preview.scrollTo(event.line);525}526}));527}528529copyImage(id: string) {530this._webviewPanel.reveal();531this._preview.postMessage({532type: 'copyImage',533source: this.resource.toString(),534id: id535});536}537538private readonly _onDispose = this._register(new vscode.EventEmitter<void>());539public readonly onDispose = this._onDispose.event;540541private readonly _onDidChangeViewState = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());542public readonly onDidChangeViewState = this._onDidChangeViewState.event;543544override dispose() {545this._onDispose.fire();546super.dispose();547}548549public matchesResource(550_otherResource: vscode.Uri,551_otherPosition: vscode.ViewColumn | undefined,552_otherLocked: boolean553): boolean {554return false;555}556557public refresh() {558this._preview.refresh(true);559}560561public updateConfiguration() {562if (this._previewConfigurations.hasConfigurationChanged(this._preview.resource)) {563this.refresh();564}565}566567public get resource() {568return this._preview.resource;569}570571public get resourceColumn() {572return this._webviewPanel.viewColumn || vscode.ViewColumn.One;573}574}575576interface DynamicPreviewInput {577readonly resource: vscode.Uri;578readonly resourceColumn: vscode.ViewColumn;579readonly locked: boolean;580readonly line?: number;581}582583export class DynamicMarkdownPreview extends Disposable implements IManagedMarkdownPreview {584585public static readonly viewType = 'markdown.preview';586587private readonly _resourceColumn: vscode.ViewColumn;588private _locked: boolean;589590private readonly _webviewPanel: vscode.WebviewPanel;591private _preview: MarkdownPreview;592593public static revive(594input: DynamicPreviewInput,595webview: vscode.WebviewPanel,596contentProvider: MdDocumentRenderer,597previewConfigurations: MarkdownPreviewConfigurationManager,598logger: ILogger,599topmostLineMonitor: TopmostLineMonitor,600contributionProvider: MarkdownContributionProvider,601opener: MdLinkOpener,602): DynamicMarkdownPreview {603webview.iconPath = contentProvider.iconPath;604605return new DynamicMarkdownPreview(webview, input,606contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, opener);607}608609public static create(610input: DynamicPreviewInput,611previewColumn: vscode.ViewColumn,612contentProvider: MdDocumentRenderer,613previewConfigurations: MarkdownPreviewConfigurationManager,614logger: ILogger,615topmostLineMonitor: TopmostLineMonitor,616contributionProvider: MarkdownContributionProvider,617opener: MdLinkOpener,618): DynamicMarkdownPreview {619const webview = vscode.window.createWebviewPanel(620DynamicMarkdownPreview.viewType,621DynamicMarkdownPreview._getPreviewTitle(input.resource, input.locked),622previewColumn, { enableFindWidget: true, });623624webview.iconPath = contentProvider.iconPath;625626return new DynamicMarkdownPreview(webview, input,627contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, opener);628}629630private constructor(631webview: vscode.WebviewPanel,632input: DynamicPreviewInput,633private readonly _contentProvider: MdDocumentRenderer,634private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,635private readonly _logger: ILogger,636private readonly _topmostLineMonitor: TopmostLineMonitor,637private readonly _contributionProvider: MarkdownContributionProvider,638private readonly _opener: MdLinkOpener,639) {640super();641642this._webviewPanel = webview;643644this._resourceColumn = input.resourceColumn;645this._locked = input.locked;646647this._preview = this._createPreview(input.resource, typeof input.line === 'number' ? new StartingScrollLine(input.line) : undefined);648649this._register(webview.onDidDispose(() => { this.dispose(); }));650651this._register(this._webviewPanel.onDidChangeViewState(e => {652this._onDidChangeViewStateEmitter.fire(e);653}));654655this._register(this._topmostLineMonitor.onDidChanged(event => {656if (this._preview.isPreviewOf(event.resource)) {657this._preview.scrollTo(event.line);658}659}));660661this._register(vscode.window.onDidChangeTextEditorSelection(event => {662if (this._preview.isPreviewOf(event.textEditor.document.uri)) {663this._preview.postMessage({664type: 'onDidChangeTextEditorSelection',665line: event.selections[0].active.line,666source: this._preview.resource.toString()667});668}669}));670671this._register(vscode.window.onDidChangeActiveTextEditor(editor => {672// Only allow previewing normal text editors which have a viewColumn: See #101514673if (typeof editor?.viewColumn === 'undefined') {674return;675}676677if (isMarkdownFile(editor.document) && !this._locked && !this._preview.isPreviewOf(editor.document.uri)) {678const line = getVisibleLine(editor);679this.update(editor.document.uri, line ? new StartingScrollLine(line) : undefined);680}681}));682}683684copyImage(id: string) {685this._webviewPanel.reveal();686this._preview.postMessage({687type: 'copyImage',688source: this.resource.toString(),689id: id690});691}692693private readonly _onDisposeEmitter = this._register(new vscode.EventEmitter<void>());694public readonly onDispose = this._onDisposeEmitter.event;695696private readonly _onDidChangeViewStateEmitter = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());697public readonly onDidChangeViewState = this._onDidChangeViewStateEmitter.event;698699override dispose() {700this._preview.dispose();701this._webviewPanel.dispose();702703this._onDisposeEmitter.fire();704this._onDisposeEmitter.dispose();705super.dispose();706}707708public get resource() {709return this._preview.resource;710}711712public get resourceColumn() {713return this._resourceColumn;714}715716public reveal(viewColumn: vscode.ViewColumn) {717this._webviewPanel.reveal(viewColumn);718}719720public refresh() {721this._preview.refresh(true);722}723724public updateConfiguration() {725if (this._previewConfigurations.hasConfigurationChanged(this._preview.resource)) {726this.refresh();727}728}729730public update(newResource: vscode.Uri, scrollLocation?: StartingScrollLocation) {731if (this._preview.isPreviewOf(newResource)) {732switch (scrollLocation?.type) {733case 'line':734this._preview.scrollTo(scrollLocation.line);735return;736737case 'fragment':738// Workaround. For fragments, just reload the entire preview739break;740741default:742return;743}744}745746this._preview.dispose();747this._preview = this._createPreview(newResource, scrollLocation);748}749750public toggleLock() {751this._locked = !this._locked;752this._webviewPanel.title = DynamicMarkdownPreview._getPreviewTitle(this._preview.resource, this._locked);753}754755private static _getPreviewTitle(resource: vscode.Uri, locked: boolean): string {756const resourceLabel = uri.Utils.basename(resource);757return locked758? vscode.l10n.t('[Preview] {0}', resourceLabel)759: vscode.l10n.t('Preview {0}', resourceLabel);760}761762public get position(): vscode.ViewColumn | undefined {763return this._webviewPanel.viewColumn;764}765766public matchesResource(767otherResource: vscode.Uri,768otherPosition: vscode.ViewColumn | undefined,769otherLocked: boolean770): boolean {771if (this.position !== otherPosition) {772return false;773}774775if (this._locked) {776return otherLocked && this._preview.isPreviewOf(otherResource);777} else {778return !otherLocked;779}780}781782public matches(otherPreview: DynamicMarkdownPreview): boolean {783return this.matchesResource(otherPreview._preview.resource, otherPreview.position, otherPreview._locked);784}785786private _createPreview(resource: vscode.Uri, startingScroll?: StartingScrollLocation): MarkdownPreview {787return new MarkdownPreview(this._webviewPanel, resource, startingScroll, {788getTitle: (resource) => DynamicMarkdownPreview._getPreviewTitle(resource, this._locked),789getAdditionalState: () => {790return {791resourceColumn: this.resourceColumn,792locked: this._locked,793};794},795openPreviewLinkToMarkdownFile: (link: vscode.Uri, fragment?: string) => {796this.update(link, fragment ? new StartingScrollFragment(fragment) : undefined);797}798},799this._contentProvider,800this._previewConfigurations,801this._logger,802this._contributionProvider,803this._opener);804}805}806807808