Merge commit 'be3e8236086165e5e45a5a10783823874b3f3ebd' as 'lib/vscode'
This commit is contained in:
@ -0,0 +1,209 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import * as nls from 'vscode-nls';
|
||||
import { OpenDocumentLinkCommand } from '../commands/openDocumentLink';
|
||||
import { getUriForLinkWithKnownExternalScheme, isOfScheme, Schemes } from '../util/links';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
function parseLink(
|
||||
document: vscode.TextDocument,
|
||||
link: string,
|
||||
): { uri: vscode.Uri, tooltip?: string } | undefined {
|
||||
const externalSchemeUri = getUriForLinkWithKnownExternalScheme(link);
|
||||
if (externalSchemeUri) {
|
||||
// Normalize VS Code links to target currently running version
|
||||
if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {
|
||||
return { uri: vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }) };
|
||||
}
|
||||
return { uri: externalSchemeUri };
|
||||
}
|
||||
|
||||
// Assume it must be an relative or absolute file path
|
||||
// Use a fake scheme to avoid parse warnings
|
||||
const tempUri = vscode.Uri.parse(`vscode-resource:${link}`);
|
||||
|
||||
let resourceUri: vscode.Uri | undefined;
|
||||
if (!tempUri.path) {
|
||||
resourceUri = document.uri;
|
||||
} else if (tempUri.path[0] === '/') {
|
||||
const root = getWorkspaceFolder(document);
|
||||
if (root) {
|
||||
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
|
||||
}
|
||||
} else {
|
||||
if (document.uri.scheme === Schemes.untitled) {
|
||||
const root = getWorkspaceFolder(document);
|
||||
if (root) {
|
||||
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
|
||||
}
|
||||
} else {
|
||||
const base = document.uri.with({ path: path.dirname(document.uri.fsPath) });
|
||||
resourceUri = vscode.Uri.joinPath(base, tempUri.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resourceUri) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
resourceUri = resourceUri.with({ fragment: tempUri.fragment });
|
||||
|
||||
return {
|
||||
uri: OpenDocumentLinkCommand.createCommandUri(document.uri, resourceUri, tempUri.fragment),
|
||||
tooltip: localize('documentLink.tooltip', 'Follow link')
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkspaceFolder(document: vscode.TextDocument) {
|
||||
return vscode.workspace.getWorkspaceFolder(document.uri)?.uri
|
||||
|| vscode.workspace.workspaceFolders?.[0]?.uri;
|
||||
}
|
||||
|
||||
function matchAll(
|
||||
pattern: RegExp,
|
||||
text: string
|
||||
): Array<RegExpMatchArray> {
|
||||
const out: RegExpMatchArray[] = [];
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpMatchArray | null;
|
||||
while ((match = pattern.exec(text))) {
|
||||
out.push(match);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractDocumentLink(
|
||||
document: vscode.TextDocument,
|
||||
pre: number,
|
||||
link: string,
|
||||
matchIndex: number | undefined
|
||||
): vscode.DocumentLink | undefined {
|
||||
const offset = (matchIndex || 0) + pre;
|
||||
const linkStart = document.positionAt(offset);
|
||||
const linkEnd = document.positionAt(offset + link.length);
|
||||
try {
|
||||
const linkData = parseLink(document, link);
|
||||
if (!linkData) {
|
||||
return undefined;
|
||||
}
|
||||
const documentLink = new vscode.DocumentLink(
|
||||
new vscode.Range(linkStart, linkEnd),
|
||||
linkData.uri);
|
||||
documentLink.tooltip = linkData.tooltip;
|
||||
return documentLink;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default class LinkProvider implements vscode.DocumentLinkProvider {
|
||||
private readonly linkPattern = /(\[((!\[[^\]]*?\]\(\s*)([^\s\(\)]+?)\s*\)\]|(?:\\\]|[^\]])*\])\(\s*)(([^\s\(\)]|\(\S*?\))+)\s*(".*?")?\)/g;
|
||||
private readonly referenceLinkPattern = /(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]/g;
|
||||
private readonly definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)(\S+)/gm;
|
||||
|
||||
public provideDocumentLinks(
|
||||
document: vscode.TextDocument,
|
||||
_token: vscode.CancellationToken
|
||||
): vscode.DocumentLink[] {
|
||||
const text = document.getText();
|
||||
|
||||
return [
|
||||
...this.providerInlineLinks(text, document),
|
||||
...this.provideReferenceLinks(text, document)
|
||||
];
|
||||
}
|
||||
|
||||
private providerInlineLinks(
|
||||
text: string,
|
||||
document: vscode.TextDocument,
|
||||
): vscode.DocumentLink[] {
|
||||
const results: vscode.DocumentLink[] = [];
|
||||
for (const match of matchAll(this.linkPattern, text)) {
|
||||
const matchImage = match[4] && extractDocumentLink(document, match[3].length + 1, match[4], match.index);
|
||||
if (matchImage) {
|
||||
results.push(matchImage);
|
||||
}
|
||||
const matchLink = extractDocumentLink(document, match[1].length, match[5], match.index);
|
||||
if (matchLink) {
|
||||
results.push(matchLink);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private provideReferenceLinks(
|
||||
text: string,
|
||||
document: vscode.TextDocument,
|
||||
): vscode.DocumentLink[] {
|
||||
const results: vscode.DocumentLink[] = [];
|
||||
|
||||
const definitions = this.getDefinitions(text, document);
|
||||
for (const match of matchAll(this.referenceLinkPattern, text)) {
|
||||
let linkStart: vscode.Position;
|
||||
let linkEnd: vscode.Position;
|
||||
let reference = match[3];
|
||||
if (reference) { // [text][ref]
|
||||
const pre = match[1];
|
||||
const offset = (match.index || 0) + pre.length;
|
||||
linkStart = document.positionAt(offset);
|
||||
linkEnd = document.positionAt(offset + reference.length);
|
||||
} else if (match[2]) { // [ref][]
|
||||
reference = match[2];
|
||||
const offset = (match.index || 0) + 1;
|
||||
linkStart = document.positionAt(offset);
|
||||
linkEnd = document.positionAt(offset + match[2].length);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const link = definitions.get(reference);
|
||||
if (link) {
|
||||
results.push(new vscode.DocumentLink(
|
||||
new vscode.Range(linkStart, linkEnd),
|
||||
vscode.Uri.parse(`command:_markdown.moveCursorToPosition?${encodeURIComponent(JSON.stringify([link.linkRange.start.line, link.linkRange.start.character]))}`)));
|
||||
}
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
for (const definition of definitions.values()) {
|
||||
try {
|
||||
const linkData = parseLink(document, definition.link);
|
||||
if (linkData) {
|
||||
results.push(new vscode.DocumentLink(definition.linkRange, linkData.uri));
|
||||
}
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private getDefinitions(text: string, document: vscode.TextDocument) {
|
||||
const out = new Map<string, { link: string, linkRange: vscode.Range }>();
|
||||
for (const match of matchAll(this.definitionPattern, text)) {
|
||||
const pre = match[1];
|
||||
const reference = match[2];
|
||||
const link = match[3].trim();
|
||||
|
||||
const offset = (match.index || 0) + pre.length;
|
||||
const linkStart = document.positionAt(offset);
|
||||
const linkEnd = document.positionAt(offset + link.length);
|
||||
|
||||
out.set(reference, {
|
||||
link: link,
|
||||
linkRange: new vscode.Range(linkStart, linkEnd)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { TableOfContentsProvider, SkinnyTextDocument, TocEntry } from '../tableOfContentsProvider';
|
||||
|
||||
interface MarkdownSymbol {
|
||||
readonly level: number;
|
||||
readonly parent: MarkdownSymbol | undefined;
|
||||
readonly children: vscode.DocumentSymbol[];
|
||||
}
|
||||
|
||||
export default class MDDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
||||
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async provideDocumentSymbolInformation(document: SkinnyTextDocument): Promise<vscode.SymbolInformation[]> {
|
||||
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
|
||||
return toc.map(entry => this.toSymbolInformation(entry));
|
||||
}
|
||||
|
||||
public async provideDocumentSymbols(document: SkinnyTextDocument): Promise<vscode.DocumentSymbol[]> {
|
||||
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
|
||||
const root: MarkdownSymbol = {
|
||||
level: -Infinity,
|
||||
children: [],
|
||||
parent: undefined
|
||||
};
|
||||
this.buildTree(root, toc);
|
||||
return root.children;
|
||||
}
|
||||
|
||||
private buildTree(parent: MarkdownSymbol, entries: TocEntry[]) {
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = entries[0];
|
||||
const symbol = this.toDocumentSymbol(entry);
|
||||
symbol.children = [];
|
||||
|
||||
while (parent && entry.level <= parent.level) {
|
||||
parent = parent.parent!;
|
||||
}
|
||||
parent.children.push(symbol);
|
||||
this.buildTree({ level: entry.level, children: symbol.children, parent }, entries.slice(1));
|
||||
}
|
||||
|
||||
|
||||
private toSymbolInformation(entry: TocEntry): vscode.SymbolInformation {
|
||||
return new vscode.SymbolInformation(
|
||||
this.getSymbolName(entry),
|
||||
vscode.SymbolKind.String,
|
||||
'',
|
||||
entry.location);
|
||||
}
|
||||
|
||||
private toDocumentSymbol(entry: TocEntry) {
|
||||
return new vscode.DocumentSymbol(
|
||||
this.getSymbolName(entry),
|
||||
'',
|
||||
vscode.SymbolKind.String,
|
||||
entry.location.range,
|
||||
entry.location.range);
|
||||
}
|
||||
|
||||
private getSymbolName(entry: TocEntry): string {
|
||||
return '#'.repeat(entry.level) + ' ' + entry.text;
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Token } from 'markdown-it';
|
||||
import * as vscode from 'vscode';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { TableOfContentsProvider } from '../tableOfContentsProvider';
|
||||
import { flatten } from '../util/arrays';
|
||||
|
||||
const rangeLimit = 5000;
|
||||
|
||||
export default class MarkdownFoldingProvider implements vscode.FoldingRangeProvider {
|
||||
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async provideFoldingRanges(
|
||||
document: vscode.TextDocument,
|
||||
_: vscode.FoldingContext,
|
||||
_token: vscode.CancellationToken
|
||||
): Promise<vscode.FoldingRange[]> {
|
||||
const foldables = await Promise.all([
|
||||
this.getRegions(document),
|
||||
this.getHeaderFoldingRanges(document),
|
||||
this.getBlockFoldingRanges(document)
|
||||
]);
|
||||
return flatten(foldables).slice(0, rangeLimit);
|
||||
}
|
||||
|
||||
private async getRegions(document: vscode.TextDocument): Promise<vscode.FoldingRange[]> {
|
||||
const tokens = await this.engine.parse(document);
|
||||
const regionMarkers = tokens.filter(isRegionMarker)
|
||||
.map(token => ({ line: token.map[0], isStart: isStartRegion(token.content) }));
|
||||
|
||||
const nestingStack: { line: number, isStart: boolean }[] = [];
|
||||
return regionMarkers
|
||||
.map(marker => {
|
||||
if (marker.isStart) {
|
||||
nestingStack.push(marker);
|
||||
} else if (nestingStack.length && nestingStack[nestingStack.length - 1].isStart) {
|
||||
return new vscode.FoldingRange(nestingStack.pop()!.line, marker.line, vscode.FoldingRangeKind.Region);
|
||||
} else {
|
||||
// noop: invalid nesting (i.e. [end, start] or [start, end, end])
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((region: vscode.FoldingRange | null): region is vscode.FoldingRange => !!region);
|
||||
}
|
||||
|
||||
private async getHeaderFoldingRanges(document: vscode.TextDocument) {
|
||||
const tocProvider = new TableOfContentsProvider(this.engine, document);
|
||||
const toc = await tocProvider.getToc();
|
||||
return toc.map(entry => {
|
||||
let endLine = entry.location.range.end.line;
|
||||
if (document.lineAt(endLine).isEmptyOrWhitespace && endLine >= entry.line + 1) {
|
||||
endLine = endLine - 1;
|
||||
}
|
||||
return new vscode.FoldingRange(entry.line, endLine);
|
||||
});
|
||||
}
|
||||
|
||||
private async getBlockFoldingRanges(document: vscode.TextDocument): Promise<vscode.FoldingRange[]> {
|
||||
const tokens = await this.engine.parse(document);
|
||||
const multiLineListItems = tokens.filter(isFoldableToken);
|
||||
return multiLineListItems.map(listItem => {
|
||||
const start = listItem.map[0];
|
||||
let end = listItem.map[1] - 1;
|
||||
if (document.lineAt(end).isEmptyOrWhitespace && end >= start + 1) {
|
||||
end = end - 1;
|
||||
}
|
||||
return new vscode.FoldingRange(start, end, this.getFoldingRangeKind(listItem));
|
||||
});
|
||||
}
|
||||
|
||||
private getFoldingRangeKind(listItem: Token): vscode.FoldingRangeKind | undefined {
|
||||
return listItem.type === 'html_block' && listItem.content.startsWith('<!--')
|
||||
? vscode.FoldingRangeKind.Comment
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const isStartRegion = (t: string) => /^\s*<!--\s*#?region\b.*-->/.test(t);
|
||||
const isEndRegion = (t: string) => /^\s*<!--\s*#?endregion\b.*-->/.test(t);
|
||||
|
||||
const isRegionMarker = (token: Token) =>
|
||||
token.type === 'html_block' && (isStartRegion(token.content) || isEndRegion(token.content));
|
||||
|
||||
const isFoldableToken = (token: Token): boolean => {
|
||||
switch (token.type) {
|
||||
case 'fence':
|
||||
case 'list_item_open':
|
||||
return token.map[1] > token.map[0];
|
||||
|
||||
case 'html_block':
|
||||
if (isRegionMarker(token)) {
|
||||
return false;
|
||||
}
|
||||
return token.map[1] > token.map[0] + 1;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
@ -0,0 +1,745 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import * as nls from 'vscode-nls';
|
||||
import { OpenDocumentLinkCommand, resolveLinkToMarkdownFile } from '../commands/openDocumentLink';
|
||||
import { Logger } from '../logger';
|
||||
import { MarkdownContributionProvider } from '../markdownExtensions';
|
||||
import { Disposable } from '../util/dispose';
|
||||
import { isMarkdownFile } from '../util/file';
|
||||
import { normalizeResource, WebviewResourceProvider } from '../util/resources';
|
||||
import { getVisibleLine, TopmostLineMonitor } from '../util/topmostLineMonitor';
|
||||
import { MarkdownPreviewConfigurationManager } from './previewConfig';
|
||||
import { MarkdownContentProvider } from './previewContentProvider';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
interface WebviewMessage {
|
||||
readonly source: string;
|
||||
}
|
||||
|
||||
interface CacheImageSizesMessage extends WebviewMessage {
|
||||
readonly type: 'cacheImageSizes';
|
||||
readonly body: { id: string, width: number, height: number; }[];
|
||||
}
|
||||
|
||||
interface RevealLineMessage extends WebviewMessage {
|
||||
readonly type: 'revealLine';
|
||||
readonly body: {
|
||||
readonly line: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DidClickMessage extends WebviewMessage {
|
||||
readonly type: 'didClick';
|
||||
readonly body: {
|
||||
readonly line: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ClickLinkMessage extends WebviewMessage {
|
||||
readonly type: 'openLink';
|
||||
readonly body: {
|
||||
readonly href: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShowPreviewSecuritySelectorMessage extends WebviewMessage {
|
||||
readonly type: 'showPreviewSecuritySelector';
|
||||
}
|
||||
|
||||
interface PreviewStyleLoadErrorMessage extends WebviewMessage {
|
||||
readonly type: 'previewStyleLoadError';
|
||||
readonly body: {
|
||||
readonly unloadedStyles: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class PreviewDocumentVersion {
|
||||
|
||||
private readonly resource: vscode.Uri;
|
||||
private readonly version: number;
|
||||
|
||||
public constructor(document: vscode.TextDocument) {
|
||||
this.resource = document.uri;
|
||||
this.version = document.version;
|
||||
}
|
||||
|
||||
public equals(other: PreviewDocumentVersion): boolean {
|
||||
return this.resource.fsPath === other.resource.fsPath
|
||||
&& this.version === other.version;
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkdownPreviewDelegate {
|
||||
getTitle?(resource: vscode.Uri): string;
|
||||
getAdditionalState(): {},
|
||||
openPreviewLinkToMarkdownFile(markdownLink: vscode.Uri, fragment: string): void;
|
||||
}
|
||||
|
||||
class StartingScrollLine {
|
||||
public readonly type = 'line';
|
||||
|
||||
constructor(
|
||||
public readonly line: number,
|
||||
) { }
|
||||
}
|
||||
|
||||
class StartingScrollFragment {
|
||||
public readonly type = 'fragment';
|
||||
|
||||
constructor(
|
||||
public readonly fragment: string,
|
||||
) { }
|
||||
}
|
||||
|
||||
type StartingScrollLocation = StartingScrollLine | StartingScrollFragment;
|
||||
|
||||
class MarkdownPreview extends Disposable implements WebviewResourceProvider {
|
||||
|
||||
private readonly delay = 300;
|
||||
|
||||
private readonly _resource: vscode.Uri;
|
||||
private readonly _webviewPanel: vscode.WebviewPanel;
|
||||
|
||||
private throttleTimer: any;
|
||||
|
||||
private line: number | undefined;
|
||||
private scrollToFragment: string | undefined;
|
||||
|
||||
private firstUpdate = true;
|
||||
private currentVersion?: PreviewDocumentVersion;
|
||||
private isScrolling = false;
|
||||
private _disposed: boolean = false;
|
||||
private imageInfo: { readonly id: string, readonly width: number, readonly height: number; }[] = [];
|
||||
|
||||
constructor(
|
||||
webview: vscode.WebviewPanel,
|
||||
resource: vscode.Uri,
|
||||
startingScroll: StartingScrollLocation | undefined,
|
||||
private readonly delegate: MarkdownPreviewDelegate,
|
||||
private readonly engine: MarkdownEngine,
|
||||
private readonly _contentProvider: MarkdownContentProvider,
|
||||
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
private readonly _logger: Logger,
|
||||
private readonly _contributionProvider: MarkdownContributionProvider,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._webviewPanel = webview;
|
||||
this._resource = resource;
|
||||
|
||||
switch (startingScroll?.type) {
|
||||
case 'line':
|
||||
if (!isNaN(startingScroll.line!)) {
|
||||
this.line = startingScroll.line;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fragment':
|
||||
this.scrollToFragment = startingScroll.fragment;
|
||||
break;
|
||||
}
|
||||
|
||||
this._register(_contributionProvider.onContributionsChanged(() => {
|
||||
setImmediate(() => this.refresh());
|
||||
}));
|
||||
|
||||
this._register(vscode.workspace.onDidChangeTextDocument(event => {
|
||||
if (this.isPreviewOf(event.document.uri)) {
|
||||
this.refresh();
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(this._webviewPanel.webview.onDidReceiveMessage((e: CacheImageSizesMessage | RevealLineMessage | DidClickMessage | ClickLinkMessage | ShowPreviewSecuritySelectorMessage | PreviewStyleLoadErrorMessage) => {
|
||||
if (e.source !== this._resource.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.type) {
|
||||
case 'cacheImageSizes':
|
||||
this.imageInfo = e.body;
|
||||
break;
|
||||
|
||||
case 'revealLine':
|
||||
this.onDidScrollPreview(e.body.line);
|
||||
break;
|
||||
|
||||
case 'didClick':
|
||||
this.onDidClickPreview(e.body.line);
|
||||
break;
|
||||
|
||||
case 'openLink':
|
||||
this.onDidClickPreviewLink(e.body.href);
|
||||
break;
|
||||
|
||||
case 'showPreviewSecuritySelector':
|
||||
vscode.commands.executeCommand('markdown.showPreviewSecuritySelector', e.source);
|
||||
break;
|
||||
|
||||
case 'previewStyleLoadError':
|
||||
vscode.window.showWarningMessage(
|
||||
localize('onPreviewStyleLoadError',
|
||||
"Could not load 'markdown.styles': {0}",
|
||||
e.body.unloadedStyles.join(', ')));
|
||||
break;
|
||||
}
|
||||
}));
|
||||
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
super.dispose();
|
||||
this._disposed = true;
|
||||
clearTimeout(this.throttleTimer);
|
||||
}
|
||||
|
||||
public get resource(): vscode.Uri {
|
||||
return this._resource;
|
||||
}
|
||||
|
||||
public get state() {
|
||||
return {
|
||||
resource: this._resource.toString(),
|
||||
line: this.line,
|
||||
imageInfo: this.imageInfo,
|
||||
fragment: this.scrollToFragment,
|
||||
...this.delegate.getAdditionalState(),
|
||||
};
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
// Schedule update if none is pending
|
||||
if (!this.throttleTimer) {
|
||||
if (this.firstUpdate) {
|
||||
this.updatePreview(true);
|
||||
} else {
|
||||
this.throttleTimer = setTimeout(() => this.updatePreview(true), this.delay);
|
||||
}
|
||||
}
|
||||
|
||||
this.firstUpdate = false;
|
||||
}
|
||||
|
||||
private get iconPath() {
|
||||
const root = vscode.Uri.joinPath(this._contributionProvider.extensionUri, 'media');
|
||||
return {
|
||||
light: vscode.Uri.joinPath(root, 'preview-light.svg'),
|
||||
dark: vscode.Uri.joinPath(root, 'preview-dark.svg'),
|
||||
};
|
||||
}
|
||||
|
||||
public isPreviewOf(resource: vscode.Uri): boolean {
|
||||
return this._resource.fsPath === resource.fsPath;
|
||||
}
|
||||
|
||||
public postMessage(msg: any) {
|
||||
if (!this._disposed) {
|
||||
this._webviewPanel.webview.postMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
public scrollTo(topLine: number) {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.log('updateForView', { markdownFile: this._resource });
|
||||
this.line = topLine;
|
||||
this.postMessage({
|
||||
type: 'updateView',
|
||||
line: topLine,
|
||||
source: this._resource.toString()
|
||||
});
|
||||
}
|
||||
|
||||
private async updatePreview(forceUpdate?: boolean): Promise<void> {
|
||||
clearTimeout(this.throttleTimer);
|
||||
this.throttleTimer = undefined;
|
||||
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let document: vscode.TextDocument;
|
||||
try {
|
||||
document = await vscode.workspace.openTextDocument(this._resource);
|
||||
} catch {
|
||||
await this.showFileNotFoundError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingVersion = new PreviewDocumentVersion(document);
|
||||
if (!forceUpdate && this.currentVersion?.equals(pendingVersion)) {
|
||||
if (this.line) {
|
||||
this.scrollTo(this.line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentVersion = pendingVersion;
|
||||
const content = await this._contentProvider.provideTextDocumentContent(document, this, this._previewConfigurations, this.line, this.state);
|
||||
|
||||
// Another call to `doUpdate` may have happened.
|
||||
// Make sure we are still updating for the correct document
|
||||
if (this.currentVersion?.equals(pendingVersion)) {
|
||||
this.setContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
private onDidScrollPreview(line: number) {
|
||||
this.line = line;
|
||||
|
||||
const config = this._previewConfigurations.loadAndCacheConfiguration(this._resource);
|
||||
if (!config.scrollEditorWithPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (!this.isPreviewOf(editor.document.uri)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.isScrolling = true;
|
||||
const sourceLine = Math.floor(line);
|
||||
const fraction = line - sourceLine;
|
||||
const text = editor.document.lineAt(sourceLine).text;
|
||||
const start = Math.floor(fraction * text.length);
|
||||
editor.revealRange(
|
||||
new vscode.Range(sourceLine, start, sourceLine + 1, 0),
|
||||
vscode.TextEditorRevealType.AtTop);
|
||||
}
|
||||
}
|
||||
|
||||
private async onDidClickPreview(line: number): Promise<void> {
|
||||
// fix #82457, find currently opened but unfocused source tab
|
||||
await vscode.commands.executeCommand('markdown.showSource');
|
||||
|
||||
for (const visibleEditor of vscode.window.visibleTextEditors) {
|
||||
if (this.isPreviewOf(visibleEditor.document.uri)) {
|
||||
const editor = await vscode.window.showTextDocument(visibleEditor.document, visibleEditor.viewColumn);
|
||||
const position = new vscode.Position(line, 0);
|
||||
editor.selection = new vscode.Selection(position, position);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
vscode.workspace.openTextDocument(this._resource)
|
||||
.then(vscode.window.showTextDocument)
|
||||
.then(undefined, () => {
|
||||
vscode.window.showErrorMessage(localize('preview.clickOpenFailed', 'Could not open {0}', this._resource.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
private async showFileNotFoundError() {
|
||||
this._webviewPanel.webview.html = this._contentProvider.provideFileNotFoundContent(this._resource);
|
||||
}
|
||||
|
||||
private setContent(html: string): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.delegate.getTitle) {
|
||||
this._webviewPanel.title = this.delegate.getTitle(this._resource);
|
||||
}
|
||||
this._webviewPanel.iconPath = this.iconPath;
|
||||
this._webviewPanel.webview.options = this.getWebviewOptions();
|
||||
|
||||
this._webviewPanel.webview.html = html;
|
||||
}
|
||||
|
||||
private getWebviewOptions(): vscode.WebviewOptions {
|
||||
return {
|
||||
enableScripts: true,
|
||||
localResourceRoots: this.getLocalResourceRoots()
|
||||
};
|
||||
}
|
||||
|
||||
private getLocalResourceRoots(): ReadonlyArray<vscode.Uri> {
|
||||
const baseRoots = Array.from(this._contributionProvider.contributions.previewResourceRoots);
|
||||
|
||||
const folder = vscode.workspace.getWorkspaceFolder(this._resource);
|
||||
if (folder) {
|
||||
const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri);
|
||||
if (workspaceRoots) {
|
||||
baseRoots.push(...workspaceRoots);
|
||||
}
|
||||
} else if (!this._resource.scheme || this._resource.scheme === 'file') {
|
||||
baseRoots.push(vscode.Uri.file(path.dirname(this._resource.fsPath)));
|
||||
}
|
||||
|
||||
return baseRoots.map(root => normalizeResource(this._resource, root));
|
||||
}
|
||||
|
||||
|
||||
private async onDidClickPreviewLink(href: string) {
|
||||
let [hrefPath, fragment] = decodeURIComponent(href).split('#');
|
||||
|
||||
// We perviously already resolve absolute paths.
|
||||
// Now make sure we handle relative file paths
|
||||
if (hrefPath[0] !== '/') {
|
||||
// Fix #93691, use this.resource.fsPath instead of this.resource.path
|
||||
hrefPath = path.join(path.dirname(this.resource.fsPath), hrefPath);
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('markdown', this.resource);
|
||||
const openLinks = config.get<string>('preview.openMarkdownLinks', 'inPreview');
|
||||
if (openLinks === 'inPreview') {
|
||||
const markdownLink = await resolveLinkToMarkdownFile(hrefPath);
|
||||
if (markdownLink) {
|
||||
this.delegate.openPreviewLinkToMarkdownFile(markdownLink, fragment);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
OpenDocumentLinkCommand.execute(this.engine, { path: hrefPath, fragment, fromResource: this.resource.toJSON() });
|
||||
}
|
||||
|
||||
//#region WebviewResourceProvider
|
||||
|
||||
asWebviewUri(resource: vscode.Uri) {
|
||||
return this._webviewPanel.webview.asWebviewUri(normalizeResource(this._resource, resource));
|
||||
}
|
||||
|
||||
get cspSource() {
|
||||
return this._webviewPanel.webview.cspSource;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export interface ManagedMarkdownPreview {
|
||||
|
||||
readonly resource: vscode.Uri;
|
||||
readonly resourceColumn: vscode.ViewColumn;
|
||||
|
||||
readonly onDispose: vscode.Event<void>;
|
||||
readonly onDidChangeViewState: vscode.Event<vscode.WebviewPanelOnDidChangeViewStateEvent>;
|
||||
|
||||
dispose(): void;
|
||||
|
||||
refresh(): void;
|
||||
updateConfiguration(): void;
|
||||
|
||||
matchesResource(
|
||||
otherResource: vscode.Uri,
|
||||
otherPosition: vscode.ViewColumn | undefined,
|
||||
otherLocked: boolean
|
||||
): boolean;
|
||||
}
|
||||
|
||||
export class StaticMarkdownPreview extends Disposable implements ManagedMarkdownPreview {
|
||||
|
||||
public static revive(
|
||||
resource: vscode.Uri,
|
||||
webview: vscode.WebviewPanel,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
): StaticMarkdownPreview {
|
||||
return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, logger, contributionProvider, engine);
|
||||
}
|
||||
|
||||
private readonly preview: MarkdownPreview;
|
||||
|
||||
private constructor(
|
||||
private readonly _webviewPanel: vscode.WebviewPanel,
|
||||
resource: vscode.Uri,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.preview = this._register(new MarkdownPreview(this._webviewPanel, resource, undefined, {
|
||||
getAdditionalState: () => { return {}; },
|
||||
openPreviewLinkToMarkdownFile: () => { /* todo */ }
|
||||
}, engine, contentProvider, _previewConfigurations, logger, contributionProvider));
|
||||
|
||||
this._register(this._webviewPanel.onDidDispose(() => {
|
||||
this.dispose();
|
||||
}));
|
||||
|
||||
this._register(this._webviewPanel.onDidChangeViewState(e => {
|
||||
this._onDidChangeViewState.fire(e);
|
||||
}));
|
||||
}
|
||||
|
||||
private readonly _onDispose = this._register(new vscode.EventEmitter<void>());
|
||||
public readonly onDispose = this._onDispose.event;
|
||||
|
||||
private readonly _onDidChangeViewState = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());
|
||||
public readonly onDidChangeViewState = this._onDidChangeViewState.event;
|
||||
|
||||
dispose() {
|
||||
this._onDispose.fire();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public matchesResource(
|
||||
_otherResource: vscode.Uri,
|
||||
_otherPosition: vscode.ViewColumn | undefined,
|
||||
_otherLocked: boolean
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
this.preview.refresh();
|
||||
}
|
||||
|
||||
public updateConfiguration() {
|
||||
if (this._previewConfigurations.hasConfigurationChanged(this.preview.resource)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public get resource() {
|
||||
return this.preview.resource;
|
||||
}
|
||||
|
||||
public get resourceColumn() {
|
||||
return this._webviewPanel.viewColumn || vscode.ViewColumn.One;
|
||||
}
|
||||
}
|
||||
|
||||
interface DynamicPreviewInput {
|
||||
readonly resource: vscode.Uri;
|
||||
readonly resourceColumn: vscode.ViewColumn;
|
||||
readonly locked: boolean;
|
||||
readonly line?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A
|
||||
*/
|
||||
export class DynamicMarkdownPreview extends Disposable implements ManagedMarkdownPreview {
|
||||
|
||||
public static readonly viewType = 'markdown.preview';
|
||||
|
||||
private readonly _resourceColumn: vscode.ViewColumn;
|
||||
private _locked: boolean;
|
||||
|
||||
private readonly _webviewPanel: vscode.WebviewPanel;
|
||||
private _preview: MarkdownPreview;
|
||||
|
||||
public static revive(
|
||||
input: DynamicPreviewInput,
|
||||
webview: vscode.WebviewPanel,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
topmostLineMonitor: TopmostLineMonitor,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
): DynamicMarkdownPreview {
|
||||
return new DynamicMarkdownPreview(webview, input,
|
||||
contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, engine);
|
||||
}
|
||||
|
||||
public static create(
|
||||
input: DynamicPreviewInput,
|
||||
previewColumn: vscode.ViewColumn,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
topmostLineMonitor: TopmostLineMonitor,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
): DynamicMarkdownPreview {
|
||||
const webview = vscode.window.createWebviewPanel(
|
||||
DynamicMarkdownPreview.viewType,
|
||||
DynamicMarkdownPreview.getPreviewTitle(input.resource, input.locked),
|
||||
previewColumn, { enableFindWidget: true, });
|
||||
|
||||
return new DynamicMarkdownPreview(webview, input,
|
||||
contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, engine);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
webview: vscode.WebviewPanel,
|
||||
input: DynamicPreviewInput,
|
||||
private readonly _contentProvider: MarkdownContentProvider,
|
||||
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
private readonly _logger: Logger,
|
||||
private readonly _topmostLineMonitor: TopmostLineMonitor,
|
||||
private readonly _contributionProvider: MarkdownContributionProvider,
|
||||
private readonly _engine: MarkdownEngine,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._webviewPanel = webview;
|
||||
|
||||
this._resourceColumn = input.resourceColumn;
|
||||
this._locked = input.locked;
|
||||
|
||||
this._preview = this.createPreview(input.resource, typeof input.line === 'number' ? new StartingScrollLine(input.line) : undefined);
|
||||
|
||||
this._register(webview.onDidDispose(() => { this.dispose(); }));
|
||||
|
||||
this._register(this._webviewPanel.onDidChangeViewState(e => {
|
||||
this._onDidChangeViewStateEmitter.fire(e);
|
||||
}));
|
||||
|
||||
this._register(this._topmostLineMonitor.onDidChanged(event => {
|
||||
if (this._preview.isPreviewOf(event.resource)) {
|
||||
this._preview.scrollTo(event.line);
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(vscode.window.onDidChangeTextEditorSelection(event => {
|
||||
if (this._preview.isPreviewOf(event.textEditor.document.uri)) {
|
||||
this._preview.postMessage({
|
||||
type: 'onDidChangeTextEditorSelection',
|
||||
line: event.selections[0].active.line,
|
||||
source: this._preview.resource.toString()
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
// Only allow previewing normal text editors which have a viewColumn: See #101514
|
||||
if (typeof editor?.viewColumn === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkdownFile(editor.document) && !this._locked && !this._preview.isPreviewOf(editor.document.uri)) {
|
||||
const line = getVisibleLine(editor);
|
||||
this.update(editor.document.uri, line ? new StartingScrollLine(line) : undefined);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private readonly _onDisposeEmitter = this._register(new vscode.EventEmitter<void>());
|
||||
public readonly onDispose = this._onDisposeEmitter.event;
|
||||
|
||||
private readonly _onDidChangeViewStateEmitter = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());
|
||||
public readonly onDidChangeViewState = this._onDidChangeViewStateEmitter.event;
|
||||
|
||||
dispose() {
|
||||
this._preview.dispose();
|
||||
this._webviewPanel.dispose();
|
||||
|
||||
this._onDisposeEmitter.fire();
|
||||
this._onDisposeEmitter.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public get resource() {
|
||||
return this._preview.resource;
|
||||
}
|
||||
|
||||
public get resourceColumn() {
|
||||
return this._resourceColumn;
|
||||
}
|
||||
|
||||
public reveal(viewColumn: vscode.ViewColumn) {
|
||||
this._webviewPanel.reveal(viewColumn);
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
this._preview.refresh();
|
||||
}
|
||||
|
||||
public updateConfiguration() {
|
||||
if (this._previewConfigurations.hasConfigurationChanged(this._preview.resource)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public update(newResource: vscode.Uri, scrollLocation?: StartingScrollLocation) {
|
||||
if (this._preview.isPreviewOf(newResource)) {
|
||||
switch (scrollLocation?.type) {
|
||||
case 'line':
|
||||
this._preview.scrollTo(scrollLocation.line);
|
||||
return;
|
||||
|
||||
case 'fragment':
|
||||
// Workaround. For fragments, just reload the entire preview
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._preview.dispose();
|
||||
this._preview = this.createPreview(newResource, scrollLocation);
|
||||
}
|
||||
|
||||
public toggleLock() {
|
||||
this._locked = !this._locked;
|
||||
this._webviewPanel.title = DynamicMarkdownPreview.getPreviewTitle(this._preview.resource, this._locked);
|
||||
}
|
||||
|
||||
private static getPreviewTitle(resource: vscode.Uri, locked: boolean): string {
|
||||
return locked
|
||||
? localize('lockedPreviewTitle', '[Preview] {0}', path.basename(resource.fsPath))
|
||||
: localize('previewTitle', 'Preview {0}', path.basename(resource.fsPath));
|
||||
}
|
||||
|
||||
public get position(): vscode.ViewColumn | undefined {
|
||||
return this._webviewPanel.viewColumn;
|
||||
}
|
||||
|
||||
public matchesResource(
|
||||
otherResource: vscode.Uri,
|
||||
otherPosition: vscode.ViewColumn | undefined,
|
||||
otherLocked: boolean
|
||||
): boolean {
|
||||
if (this.position !== otherPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._locked) {
|
||||
return otherLocked && this._preview.isPreviewOf(otherResource);
|
||||
} else {
|
||||
return !otherLocked;
|
||||
}
|
||||
}
|
||||
|
||||
public matches(otherPreview: DynamicMarkdownPreview): boolean {
|
||||
return this.matchesResource(otherPreview._preview.resource, otherPreview.position, otherPreview._locked);
|
||||
}
|
||||
|
||||
private createPreview(resource: vscode.Uri, startingScroll?: StartingScrollLocation): MarkdownPreview {
|
||||
return new MarkdownPreview(this._webviewPanel, resource, startingScroll, {
|
||||
getTitle: (resource) => DynamicMarkdownPreview.getPreviewTitle(resource, this._locked),
|
||||
getAdditionalState: () => {
|
||||
return {
|
||||
resourceColumn: this.resourceColumn,
|
||||
locked: this._locked,
|
||||
};
|
||||
},
|
||||
openPreviewLinkToMarkdownFile: (link: vscode.Uri, fragment?: string) => {
|
||||
this.update(link, fragment ? new StartingScrollFragment(fragment) : undefined);
|
||||
}
|
||||
},
|
||||
this._engine,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._contributionProvider);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,93 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { equals } from '../util/arrays';
|
||||
|
||||
export class MarkdownPreviewConfiguration {
|
||||
public static getForResource(resource: vscode.Uri) {
|
||||
return new MarkdownPreviewConfiguration(resource);
|
||||
}
|
||||
|
||||
public readonly scrollBeyondLastLine: boolean;
|
||||
public readonly wordWrap: boolean;
|
||||
public readonly lineBreaks: boolean;
|
||||
public readonly doubleClickToSwitchToEditor: boolean;
|
||||
public readonly scrollEditorWithPreview: boolean;
|
||||
public readonly scrollPreviewWithEditor: boolean;
|
||||
public readonly markEditorSelection: boolean;
|
||||
|
||||
public readonly lineHeight: number;
|
||||
public readonly fontSize: number;
|
||||
public readonly fontFamily: string | undefined;
|
||||
public readonly styles: readonly string[];
|
||||
|
||||
private constructor(resource: vscode.Uri) {
|
||||
const editorConfig = vscode.workspace.getConfiguration('editor', resource);
|
||||
const markdownConfig = vscode.workspace.getConfiguration('markdown', resource);
|
||||
const markdownEditorConfig = vscode.workspace.getConfiguration('[markdown]', resource);
|
||||
|
||||
this.scrollBeyondLastLine = editorConfig.get<boolean>('scrollBeyondLastLine', false);
|
||||
|
||||
this.wordWrap = editorConfig.get<string>('wordWrap', 'off') !== 'off';
|
||||
if (markdownEditorConfig && markdownEditorConfig['editor.wordWrap']) {
|
||||
this.wordWrap = markdownEditorConfig['editor.wordWrap'] !== 'off';
|
||||
}
|
||||
|
||||
this.scrollPreviewWithEditor = !!markdownConfig.get<boolean>('preview.scrollPreviewWithEditor', true);
|
||||
this.scrollEditorWithPreview = !!markdownConfig.get<boolean>('preview.scrollEditorWithPreview', true);
|
||||
this.lineBreaks = !!markdownConfig.get<boolean>('preview.breaks', false);
|
||||
this.doubleClickToSwitchToEditor = !!markdownConfig.get<boolean>('preview.doubleClickToSwitchToEditor', true);
|
||||
this.markEditorSelection = !!markdownConfig.get<boolean>('preview.markEditorSelection', true);
|
||||
|
||||
this.fontFamily = markdownConfig.get<string | undefined>('preview.fontFamily', undefined);
|
||||
this.fontSize = Math.max(8, +markdownConfig.get<number>('preview.fontSize', NaN));
|
||||
this.lineHeight = Math.max(0.6, +markdownConfig.get<number>('preview.lineHeight', NaN));
|
||||
|
||||
this.styles = markdownConfig.get<string[]>('styles', []);
|
||||
}
|
||||
|
||||
public isEqualTo(otherConfig: MarkdownPreviewConfiguration) {
|
||||
for (const key in this) {
|
||||
if (this.hasOwnProperty(key) && key !== 'styles') {
|
||||
if (this[key] !== otherConfig[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return equals(this.styles, otherConfig.styles);
|
||||
}
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class MarkdownPreviewConfigurationManager {
|
||||
private readonly previewConfigurationsForWorkspaces = new Map<string, MarkdownPreviewConfiguration>();
|
||||
|
||||
public loadAndCacheConfiguration(
|
||||
resource: vscode.Uri
|
||||
): MarkdownPreviewConfiguration {
|
||||
const config = MarkdownPreviewConfiguration.getForResource(resource);
|
||||
this.previewConfigurationsForWorkspaces.set(this.getKey(resource), config);
|
||||
return config;
|
||||
}
|
||||
|
||||
public hasConfigurationChanged(
|
||||
resource: vscode.Uri
|
||||
): boolean {
|
||||
const key = this.getKey(resource);
|
||||
const currentConfig = this.previewConfigurationsForWorkspaces.get(key);
|
||||
const newConfig = MarkdownPreviewConfiguration.getForResource(resource);
|
||||
return (!currentConfig || !currentConfig.isEqualTo(newConfig));
|
||||
}
|
||||
|
||||
private getKey(
|
||||
resource: vscode.Uri
|
||||
): string {
|
||||
const folder = vscode.workspace.getWorkspaceFolder(resource);
|
||||
return folder ? folder.uri.toString() : '';
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import * as nls from 'vscode-nls';
|
||||
import { Logger } from '../logger';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { MarkdownContributionProvider } from '../markdownExtensions';
|
||||
import { ContentSecurityPolicyArbiter, MarkdownPreviewSecurityLevel } from '../security';
|
||||
import { WebviewResourceProvider } from '../util/resources';
|
||||
import { MarkdownPreviewConfiguration, MarkdownPreviewConfigurationManager } from './previewConfig';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
/**
|
||||
* Strings used inside the markdown preview.
|
||||
*
|
||||
* Stored here and then injected in the preview so that they
|
||||
* can be localized using our normal localization process.
|
||||
*/
|
||||
const previewStrings = {
|
||||
cspAlertMessageText: localize(
|
||||
'preview.securityMessage.text',
|
||||
'Some content has been disabled in this document'),
|
||||
|
||||
cspAlertMessageTitle: localize(
|
||||
'preview.securityMessage.title',
|
||||
'Potentially unsafe or insecure content has been disabled in the markdown preview. Change the Markdown preview security setting to allow insecure content or enable scripts'),
|
||||
|
||||
cspAlertMessageLabel: localize(
|
||||
'preview.securityMessage.label',
|
||||
'Content Disabled Security Warning')
|
||||
};
|
||||
|
||||
function escapeAttribute(value: string | vscode.Uri): string {
|
||||
return value.toString().replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export class MarkdownContentProvider {
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine,
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly cspArbiter: ContentSecurityPolicyArbiter,
|
||||
private readonly contributionProvider: MarkdownContributionProvider,
|
||||
private readonly logger: Logger
|
||||
) { }
|
||||
|
||||
public async provideTextDocumentContent(
|
||||
markdownDocument: vscode.TextDocument,
|
||||
resourceProvider: WebviewResourceProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
initialLine: number | undefined = undefined,
|
||||
state?: any
|
||||
): Promise<string> {
|
||||
const sourceUri = markdownDocument.uri;
|
||||
const config = previewConfigurations.loadAndCacheConfiguration(sourceUri);
|
||||
const initialData = {
|
||||
source: sourceUri.toString(),
|
||||
line: initialLine,
|
||||
lineCount: markdownDocument.lineCount,
|
||||
scrollPreviewWithEditor: config.scrollPreviewWithEditor,
|
||||
scrollEditorWithPreview: config.scrollEditorWithPreview,
|
||||
doubleClickToSwitchToEditor: config.doubleClickToSwitchToEditor,
|
||||
disableSecurityWarnings: this.cspArbiter.shouldDisableSecurityWarnings(),
|
||||
webviewResourceRoot: resourceProvider.asWebviewUri(markdownDocument.uri).toString(),
|
||||
};
|
||||
|
||||
this.logger.log('provideTextDocumentContent', initialData);
|
||||
|
||||
// Content Security Policy
|
||||
const nonce = new Date().getTime() + '' + new Date().getMilliseconds();
|
||||
const csp = this.getCsp(resourceProvider, sourceUri, nonce);
|
||||
|
||||
const body = await this.engine.render(markdownDocument);
|
||||
return `<!DOCTYPE html>
|
||||
<html style="${escapeAttribute(this.getSettingsOverrideStyles(config))}">
|
||||
<head>
|
||||
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
||||
${csp}
|
||||
<meta id="vscode-markdown-preview-data"
|
||||
data-settings="${escapeAttribute(JSON.stringify(initialData))}"
|
||||
data-strings="${escapeAttribute(JSON.stringify(previewStrings))}"
|
||||
data-state="${escapeAttribute(JSON.stringify(state || {}))}">
|
||||
<script src="${this.extensionResourcePath(resourceProvider, 'pre.js')}" nonce="${nonce}"></script>
|
||||
${this.getStyles(resourceProvider, sourceUri, config, state)}
|
||||
<base href="${resourceProvider.asWebviewUri(markdownDocument.uri)}">
|
||||
</head>
|
||||
<body class="vscode-body ${config.scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${config.wordWrap ? 'wordWrap' : ''} ${config.markEditorSelection ? 'showEditorSelection' : ''}">
|
||||
${body}
|
||||
<div class="code-line" data-line="${markdownDocument.lineCount}"></div>
|
||||
${this.getScripts(resourceProvider, nonce)}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
public provideFileNotFoundContent(
|
||||
resource: vscode.Uri,
|
||||
): string {
|
||||
const resourcePath = path.basename(resource.fsPath);
|
||||
const body = localize('preview.notFound', '{0} cannot be found', resourcePath);
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<body class="vscode-body">
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string {
|
||||
const webviewResource = resourceProvider.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.context.extensionUri, 'media', mediaFile));
|
||||
return webviewResource.toString();
|
||||
}
|
||||
|
||||
private fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string {
|
||||
if (!href) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (href.startsWith('http:') || href.startsWith('https:') || href.startsWith('file:')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
// Assume it must be a local file
|
||||
if (path.isAbsolute(href)) {
|
||||
return resourceProvider.asWebviewUri(vscode.Uri.file(href)).toString();
|
||||
}
|
||||
|
||||
// Use a workspace relative path if there is a workspace
|
||||
const root = vscode.workspace.getWorkspaceFolder(resource);
|
||||
if (root) {
|
||||
return resourceProvider.asWebviewUri(vscode.Uri.joinPath(root.uri, href)).toString();
|
||||
}
|
||||
|
||||
// Otherwise look relative to the markdown file
|
||||
return resourceProvider.asWebviewUri(vscode.Uri.file(path.join(path.dirname(resource.fsPath), href))).toString();
|
||||
}
|
||||
|
||||
private computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string {
|
||||
if (!Array.isArray(config.styles)) {
|
||||
return '';
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const style of config.styles) {
|
||||
out.push(`<link rel="stylesheet" class="code-user-style" data-source="${escapeAttribute(style)}" href="${escapeAttribute(this.fixHref(resourceProvider, resource, style))}" type="text/css" media="screen">`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
private getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string {
|
||||
return [
|
||||
config.fontFamily ? `--markdown-font-family: ${config.fontFamily};` : '',
|
||||
isNaN(config.fontSize) ? '' : `--markdown-font-size: ${config.fontSize}px;`,
|
||||
isNaN(config.lineHeight) ? '' : `--markdown-line-height: ${config.lineHeight};`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
private getImageStabilizerStyles(state?: any) {
|
||||
let ret = '<style>\n';
|
||||
if (state && state.imageInfo) {
|
||||
state.imageInfo.forEach((imgInfo: any) => {
|
||||
ret += `#${imgInfo.id}.loading {
|
||||
height: ${imgInfo.height}px;
|
||||
width: ${imgInfo.width}px;
|
||||
}\n`;
|
||||
});
|
||||
}
|
||||
ret += '</style>\n';
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, state?: any): string {
|
||||
const baseStyles: string[] = [];
|
||||
for (const resource of this.contributionProvider.contributions.previewStyles) {
|
||||
baseStyles.push(`<link rel="stylesheet" type="text/css" href="${escapeAttribute(resourceProvider.asWebviewUri(resource))}">`);
|
||||
}
|
||||
|
||||
return `${baseStyles.join('\n')}
|
||||
${this.computeCustomStyleSheetIncludes(resourceProvider, resource, config)}
|
||||
${this.getImageStabilizerStyles(state)}`;
|
||||
}
|
||||
|
||||
private getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string {
|
||||
const out: string[] = [];
|
||||
for (const resource of this.contributionProvider.contributions.previewScripts) {
|
||||
out.push(`<script async
|
||||
src="${escapeAttribute(resourceProvider.asWebviewUri(resource))}"
|
||||
nonce="${nonce}"
|
||||
charset="UTF-8"></script>`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
private getCsp(
|
||||
provider: WebviewResourceProvider,
|
||||
resource: vscode.Uri,
|
||||
nonce: string
|
||||
): string {
|
||||
const rule = provider.cspSource;
|
||||
switch (this.cspArbiter.getSecurityLevelForResource(resource)) {
|
||||
case MarkdownPreviewSecurityLevel.AllowInsecureContent:
|
||||
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} http: https: data:; media-src 'self' ${rule} http: https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' http: https: data:; font-src 'self' ${rule} http: https: data:;">`;
|
||||
|
||||
case MarkdownPreviewSecurityLevel.AllowInsecureLocalContent:
|
||||
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; media-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data: http://localhost:* http://127.0.0.1:*; font-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*;">`;
|
||||
|
||||
case MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent:
|
||||
return '<meta http-equiv="Content-Security-Policy" content="">';
|
||||
|
||||
case MarkdownPreviewSecurityLevel.Strict:
|
||||
default:
|
||||
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} https: data:; media-src 'self' ${rule} https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data:; font-src 'self' ${rule} https: data:;">`;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,239 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { Logger } from '../logger';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { MarkdownContributionProvider } from '../markdownExtensions';
|
||||
import { Disposable, disposeAll } from '../util/dispose';
|
||||
import { TopmostLineMonitor } from '../util/topmostLineMonitor';
|
||||
import { DynamicMarkdownPreview, ManagedMarkdownPreview, StaticMarkdownPreview } from './preview';
|
||||
import { MarkdownPreviewConfigurationManager } from './previewConfig';
|
||||
import { MarkdownContentProvider } from './previewContentProvider';
|
||||
|
||||
export interface DynamicPreviewSettings {
|
||||
readonly resourceColumn: vscode.ViewColumn;
|
||||
readonly previewColumn: vscode.ViewColumn;
|
||||
readonly locked: boolean;
|
||||
}
|
||||
|
||||
class PreviewStore<T extends ManagedMarkdownPreview> extends Disposable {
|
||||
|
||||
private readonly _previews = new Set<T>();
|
||||
|
||||
public dispose(): void {
|
||||
super.dispose();
|
||||
for (const preview of this._previews) {
|
||||
preview.dispose();
|
||||
}
|
||||
this._previews.clear();
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<T> {
|
||||
return this._previews[Symbol.iterator]();
|
||||
}
|
||||
|
||||
public get(resource: vscode.Uri, previewSettings: DynamicPreviewSettings): T | undefined {
|
||||
for (const preview of this._previews) {
|
||||
if (preview.matchesResource(resource, previewSettings.previewColumn, previewSettings.locked)) {
|
||||
return preview;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public add(preview: T) {
|
||||
this._previews.add(preview);
|
||||
}
|
||||
|
||||
public delete(preview: T) {
|
||||
this._previews.delete(preview);
|
||||
}
|
||||
}
|
||||
|
||||
export class MarkdownPreviewManager extends Disposable implements vscode.WebviewPanelSerializer, vscode.CustomTextEditorProvider {
|
||||
private static readonly markdownPreviewActiveContextKey = 'markdownPreviewFocus';
|
||||
|
||||
private readonly _topmostLineMonitor = new TopmostLineMonitor();
|
||||
private readonly _previewConfigurations = new MarkdownPreviewConfigurationManager();
|
||||
|
||||
private readonly _dynamicPreviews = this._register(new PreviewStore<DynamicMarkdownPreview>());
|
||||
private readonly _staticPreviews = this._register(new PreviewStore<StaticMarkdownPreview>());
|
||||
|
||||
private _activePreview: ManagedMarkdownPreview | undefined = undefined;
|
||||
|
||||
private readonly customEditorViewType = 'vscode.markdown.preview.editor';
|
||||
|
||||
public constructor(
|
||||
private readonly _contentProvider: MarkdownContentProvider,
|
||||
private readonly _logger: Logger,
|
||||
private readonly _contributions: MarkdownContributionProvider,
|
||||
private readonly _engine: MarkdownEngine,
|
||||
) {
|
||||
super();
|
||||
this._register(vscode.window.registerWebviewPanelSerializer(DynamicMarkdownPreview.viewType, this));
|
||||
this._register(vscode.window.registerCustomEditorProvider(this.customEditorViewType, this));
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
for (const preview of this._dynamicPreviews) {
|
||||
preview.refresh();
|
||||
}
|
||||
for (const preview of this._staticPreviews) {
|
||||
preview.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public updateConfiguration() {
|
||||
for (const preview of this._dynamicPreviews) {
|
||||
preview.updateConfiguration();
|
||||
}
|
||||
for (const preview of this._staticPreviews) {
|
||||
preview.updateConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
public openDynamicPreview(
|
||||
resource: vscode.Uri,
|
||||
settings: DynamicPreviewSettings
|
||||
): void {
|
||||
let preview = this._dynamicPreviews.get(resource, settings);
|
||||
if (preview) {
|
||||
preview.reveal(settings.previewColumn);
|
||||
} else {
|
||||
preview = this.createNewDynamicPreview(resource, settings);
|
||||
}
|
||||
|
||||
preview.update(resource);
|
||||
}
|
||||
|
||||
public get activePreviewResource() {
|
||||
return this._activePreview?.resource;
|
||||
}
|
||||
|
||||
public get activePreviewResourceColumn() {
|
||||
return this._activePreview?.resourceColumn;
|
||||
}
|
||||
|
||||
public toggleLock() {
|
||||
const preview = this._activePreview;
|
||||
if (preview instanceof DynamicMarkdownPreview) {
|
||||
preview.toggleLock();
|
||||
|
||||
// Close any previews that are now redundant, such as having two dynamic previews in the same editor group
|
||||
for (const otherPreview of this._dynamicPreviews) {
|
||||
if (otherPreview !== preview && preview.matches(otherPreview)) {
|
||||
otherPreview.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deserializeWebviewPanel(
|
||||
webview: vscode.WebviewPanel,
|
||||
state: any
|
||||
): Promise<void> {
|
||||
const resource = vscode.Uri.parse(state.resource);
|
||||
const locked = state.locked;
|
||||
const line = state.line;
|
||||
const resourceColumn = state.resourceColumn;
|
||||
|
||||
const preview = await DynamicMarkdownPreview.revive(
|
||||
{ resource, locked, line, resourceColumn },
|
||||
webview,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._topmostLineMonitor,
|
||||
this._contributions,
|
||||
this._engine);
|
||||
|
||||
this.registerDynamicPreview(preview);
|
||||
}
|
||||
|
||||
public async resolveCustomTextEditor(
|
||||
document: vscode.TextDocument,
|
||||
webview: vscode.WebviewPanel
|
||||
): Promise<void> {
|
||||
const preview = StaticMarkdownPreview.revive(
|
||||
document.uri,
|
||||
webview,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._contributions,
|
||||
this._engine);
|
||||
this.registerStaticPreview(preview);
|
||||
}
|
||||
|
||||
private createNewDynamicPreview(
|
||||
resource: vscode.Uri,
|
||||
previewSettings: DynamicPreviewSettings
|
||||
): DynamicMarkdownPreview {
|
||||
const preview = DynamicMarkdownPreview.create(
|
||||
{
|
||||
resource,
|
||||
resourceColumn: previewSettings.resourceColumn,
|
||||
locked: previewSettings.locked,
|
||||
},
|
||||
previewSettings.previewColumn,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._topmostLineMonitor,
|
||||
this._contributions,
|
||||
this._engine);
|
||||
|
||||
this.setPreviewActiveContext(true);
|
||||
this._activePreview = preview;
|
||||
return this.registerDynamicPreview(preview);
|
||||
}
|
||||
|
||||
private registerDynamicPreview(preview: DynamicMarkdownPreview): DynamicMarkdownPreview {
|
||||
this._dynamicPreviews.add(preview);
|
||||
|
||||
preview.onDispose(() => {
|
||||
this._dynamicPreviews.delete(preview);
|
||||
});
|
||||
|
||||
this.trackActive(preview);
|
||||
|
||||
preview.onDidChangeViewState(() => {
|
||||
// Remove other dynamic previews in our column
|
||||
disposeAll(Array.from(this._dynamicPreviews).filter(otherPreview => preview !== otherPreview && preview.matches(otherPreview)));
|
||||
});
|
||||
return preview;
|
||||
}
|
||||
|
||||
private registerStaticPreview(preview: StaticMarkdownPreview): StaticMarkdownPreview {
|
||||
this._staticPreviews.add(preview);
|
||||
|
||||
preview.onDispose(() => {
|
||||
this._staticPreviews.delete(preview);
|
||||
});
|
||||
|
||||
this.trackActive(preview);
|
||||
return preview;
|
||||
}
|
||||
|
||||
private trackActive(preview: ManagedMarkdownPreview): void {
|
||||
preview.onDidChangeViewState(({ webviewPanel }) => {
|
||||
this.setPreviewActiveContext(webviewPanel.active);
|
||||
this._activePreview = webviewPanel.active ? preview : undefined;
|
||||
});
|
||||
|
||||
preview.onDispose(() => {
|
||||
if (this._activePreview === preview) {
|
||||
this.setPreviewActiveContext(false);
|
||||
this._activePreview = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setPreviewActiveContext(value: boolean) {
|
||||
vscode.commands.executeCommand('setContext', MarkdownPreviewManager.markdownPreviewActiveContextKey, value);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,238 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Token } from 'markdown-it';
|
||||
import * as vscode from 'vscode';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { TableOfContentsProvider, TocEntry } from '../tableOfContentsProvider';
|
||||
|
||||
export default class MarkdownSmartSelect implements vscode.SelectionRangeProvider {
|
||||
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async provideSelectionRanges(document: vscode.TextDocument, positions: vscode.Position[], _token: vscode.CancellationToken): Promise<vscode.SelectionRange[] | undefined> {
|
||||
let promises = await Promise.all(positions.map((position) => {
|
||||
return this.provideSelectionRange(document, position, _token);
|
||||
}));
|
||||
return promises.filter(item => item !== undefined) as vscode.SelectionRange[];
|
||||
}
|
||||
|
||||
private async provideSelectionRange(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise<vscode.SelectionRange | undefined> {
|
||||
const headerRange = await this.getHeaderSelectionRange(document, position);
|
||||
const blockRange = await this.getBlockSelectionRange(document, position, headerRange);
|
||||
return blockRange ? blockRange : headerRange ? headerRange : undefined;
|
||||
}
|
||||
|
||||
private async getBlockSelectionRange(document: vscode.TextDocument, position: vscode.Position, headerRange?: vscode.SelectionRange): Promise<vscode.SelectionRange | undefined> {
|
||||
|
||||
const tokens = await this.engine.parse(document);
|
||||
|
||||
let blockTokens = getTokensForPosition(tokens, position);
|
||||
|
||||
if (blockTokens.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let parentRange = headerRange ? headerRange : createBlockRange(document, position.line, blockTokens.shift());
|
||||
let currentRange: vscode.SelectionRange | undefined;
|
||||
|
||||
for (const token of blockTokens) {
|
||||
currentRange = createBlockRange(document, position.line, token, parentRange);
|
||||
if (currentRange) {
|
||||
parentRange = currentRange;
|
||||
}
|
||||
}
|
||||
if (currentRange) {
|
||||
return currentRange;
|
||||
} else {
|
||||
return parentRange;
|
||||
}
|
||||
}
|
||||
|
||||
private async getHeaderSelectionRange(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.SelectionRange | undefined> {
|
||||
const tocProvider = new TableOfContentsProvider(this.engine, document);
|
||||
const toc = await tocProvider.getToc();
|
||||
|
||||
let headerInfo = getHeadersForPosition(toc, position);
|
||||
|
||||
let headers = headerInfo.headers;
|
||||
|
||||
let parentRange: vscode.SelectionRange | undefined;
|
||||
let currentRange: vscode.SelectionRange | undefined;
|
||||
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
currentRange = createHeaderRange(i === headers.length - 1, headerInfo.headerOnThisLine, headers[i], parentRange, getFirstChildHeader(document, headers[i], toc));
|
||||
if (currentRange && currentRange.parent) {
|
||||
parentRange = currentRange;
|
||||
}
|
||||
}
|
||||
return currentRange;
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstChildHeader(document: vscode.TextDocument, header?: TocEntry, toc?: TocEntry[]): vscode.Position | undefined {
|
||||
let childRange: vscode.Position | undefined;
|
||||
if (header && toc) {
|
||||
let children = toc.filter(t => header.location.range.contains(t.location.range) && t.location.range.start.line > header.location.range.start.line).sort((t1, t2) => t1.line - t2.line);
|
||||
if (children.length > 0) {
|
||||
childRange = children[0].location.range.start;
|
||||
let lineText = document.lineAt(childRange.line - 1).text;
|
||||
return childRange ? childRange.translate(-1, lineText.length) : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getTokensForPosition(tokens: Token[], position: vscode.Position): Token[] {
|
||||
let enclosingTokens = tokens.filter(token => token.map && (token.map[0] <= position.line && token.map[1] > position.line) && isBlockElement(token));
|
||||
|
||||
if (enclosingTokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let sortedTokens = enclosingTokens.sort((token1, token2) => (token2.map[1] - token2.map[0]) - (token1.map[1] - token1.map[0]));
|
||||
return sortedTokens;
|
||||
}
|
||||
|
||||
function getHeadersForPosition(toc: TocEntry[], position: vscode.Position): { headers: TocEntry[], headerOnThisLine: boolean } {
|
||||
let enclosingHeaders = toc.filter(header => header.location.range.start.line <= position.line && header.location.range.end.line >= position.line);
|
||||
let sortedHeaders = enclosingHeaders.sort((header1, header2) => (header1.line - position.line) - (header2.line - position.line));
|
||||
let onThisLine = toc.find(header => header.line === position.line) !== undefined;
|
||||
return {
|
||||
headers: sortedHeaders,
|
||||
headerOnThisLine: onThisLine
|
||||
};
|
||||
}
|
||||
|
||||
function isBlockElement(token: Token): boolean {
|
||||
return !['list_item_close', 'paragraph_close', 'bullet_list_close', 'inline', 'heading_close', 'heading_open'].includes(token.type);
|
||||
}
|
||||
|
||||
function createHeaderRange(isClosestHeaderToPosition: boolean, onHeaderLine: boolean, header?: TocEntry, parent?: vscode.SelectionRange, childStart?: vscode.Position): vscode.SelectionRange | undefined {
|
||||
if (header) {
|
||||
let contentRange = new vscode.Range(header.location.range.start.translate(1), header.location.range.end);
|
||||
let headerPlusContentRange = header.location.range;
|
||||
let partialContentRange = childStart && isClosestHeaderToPosition ? contentRange.with(undefined, childStart) : undefined;
|
||||
if (onHeaderLine && isClosestHeaderToPosition && childStart) {
|
||||
return new vscode.SelectionRange(header.location.range.with(undefined, childStart), new vscode.SelectionRange(header.location.range, parent));
|
||||
} else if (onHeaderLine && isClosestHeaderToPosition) {
|
||||
return new vscode.SelectionRange(header.location.range, parent);
|
||||
} else if (parent && parent.range.contains(headerPlusContentRange)) {
|
||||
if (partialContentRange) {
|
||||
return new vscode.SelectionRange(partialContentRange, new vscode.SelectionRange(contentRange, (new vscode.SelectionRange(headerPlusContentRange, parent))));
|
||||
} else {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(headerPlusContentRange, parent));
|
||||
}
|
||||
} else if (partialContentRange) {
|
||||
return new vscode.SelectionRange(partialContentRange, new vscode.SelectionRange(contentRange, (new vscode.SelectionRange(headerPlusContentRange))));
|
||||
} else {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(headerPlusContentRange));
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createBlockRange(document: vscode.TextDocument, cursorLine: number, block?: Token, parent?: vscode.SelectionRange): vscode.SelectionRange | undefined {
|
||||
if (block) {
|
||||
if (block.type === 'fence') {
|
||||
return createFencedRange(block, cursorLine, document, parent);
|
||||
} else {
|
||||
let startLine = document.lineAt(block.map[0]).isEmptyOrWhitespace ? block.map[0] + 1 : block.map[0];
|
||||
let endLine = startLine !== block.map[1] && isList(block.type) ? block.map[1] - 1 : block.map[1];
|
||||
let startPos = new vscode.Position(startLine, 0);
|
||||
let endPos = new vscode.Position(endLine, getEndCharacter(document, startLine, endLine));
|
||||
let range = new vscode.Range(startPos, endPos);
|
||||
if (parent && parent.range.contains(range) && !parent.range.isEqual(range)) {
|
||||
return new vscode.SelectionRange(range, parent);
|
||||
} else if (parent) {
|
||||
if (rangeLinesEqual(range, parent.range)) {
|
||||
return range.end.character > parent.range.end.character ? new vscode.SelectionRange(range) : parent;
|
||||
} else if (parent.range.end.line + 1 === range.end.line) {
|
||||
let adjustedRange = new vscode.Range(range.start, range.end.translate(-1, parent.range.end.character));
|
||||
if (adjustedRange.isEqual(parent.range)) {
|
||||
return parent;
|
||||
} else {
|
||||
return new vscode.SelectionRange(adjustedRange, parent);
|
||||
}
|
||||
} else if (parent.range.end.line === range.end.line) {
|
||||
let adjustedRange = new vscode.Range(parent.range.start, range.end.translate(undefined, parent.range.end.character));
|
||||
if (adjustedRange.isEqual(parent.range)) {
|
||||
return parent;
|
||||
} else {
|
||||
return new vscode.SelectionRange(adjustedRange, parent.parent);
|
||||
}
|
||||
} else {
|
||||
return parent;
|
||||
}
|
||||
} else {
|
||||
return new vscode.SelectionRange(range);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createFencedRange(token: Token, cursorLine: number, document: vscode.TextDocument, parent?: vscode.SelectionRange): vscode.SelectionRange {
|
||||
const startLine = token.map[0];
|
||||
const endLine = token.map[1] - 1;
|
||||
let onFenceLine = cursorLine === startLine || cursorLine === endLine;
|
||||
let fenceRange = new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(endLine, document.lineAt(endLine).text.length));
|
||||
let contentRange = endLine - startLine > 2 && !onFenceLine ? new vscode.Range(new vscode.Position(startLine + 1, 0), new vscode.Position(endLine - 1, getEndCharacter(document, startLine + 1, endLine))) : undefined;
|
||||
if (parent && contentRange) {
|
||||
if (parent.range.contains(fenceRange) && !parent.range.isEqual(fenceRange)) {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange, parent));
|
||||
} else if (parent.range.isEqual(fenceRange)) {
|
||||
return new vscode.SelectionRange(contentRange, parent);
|
||||
} else if (rangeLinesEqual(fenceRange, parent.range)) {
|
||||
let revisedRange = fenceRange.end.character > parent.range.end.character ? fenceRange : parent.range;
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(revisedRange, getRealParent(parent, revisedRange)));
|
||||
} else if (parent.range.end.line === fenceRange.end.line) {
|
||||
parent.range.end.translate(undefined, fenceRange.end.character);
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange, parent));
|
||||
}
|
||||
} else if (contentRange) {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange));
|
||||
} else if (parent) {
|
||||
if (parent.range.contains(fenceRange) && !parent.range.isEqual(fenceRange)) {
|
||||
return new vscode.SelectionRange(fenceRange, parent);
|
||||
} else if (parent.range.isEqual(fenceRange)) {
|
||||
return parent;
|
||||
} else if (rangeLinesEqual(fenceRange, parent.range)) {
|
||||
let revisedRange = fenceRange.end.character > parent.range.end.character ? fenceRange : parent.range;
|
||||
return new vscode.SelectionRange(revisedRange, parent.parent);
|
||||
} else if (parent.range.end.line === fenceRange.end.line) {
|
||||
parent.range.end.translate(undefined, fenceRange.end.character);
|
||||
return new vscode.SelectionRange(fenceRange, parent);
|
||||
}
|
||||
}
|
||||
return new vscode.SelectionRange(fenceRange, parent);
|
||||
}
|
||||
|
||||
function isList(type: string): boolean {
|
||||
return type ? ['ordered_list_open', 'list_item_open', 'bullet_list_open'].includes(type) : false;
|
||||
}
|
||||
|
||||
function getEndCharacter(document: vscode.TextDocument, startLine: number, endLine: number): number {
|
||||
let startLength = document.lineAt(startLine).text ? document.lineAt(startLine).text.length : 0;
|
||||
let endLength = document.lineAt(startLine).text ? document.lineAt(startLine).text.length : 0;
|
||||
let endChar = Math.max(startLength, endLength);
|
||||
return startLine !== endLine ? 0 : endChar;
|
||||
}
|
||||
|
||||
function getRealParent(parent: vscode.SelectionRange, range: vscode.Range) {
|
||||
let currentParent: vscode.SelectionRange | undefined = parent;
|
||||
while (currentParent && !currentParent.range.contains(range)) {
|
||||
currentParent = currentParent.parent;
|
||||
}
|
||||
return currentParent;
|
||||
}
|
||||
|
||||
function rangeLinesEqual(range: vscode.Range, parent: vscode.Range) {
|
||||
return range.start.line === parent.start.line && range.end.line === parent.end.line;
|
||||
}
|
@ -0,0 +1,163 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { Disposable } from '../util/dispose';
|
||||
import { isMarkdownFile } from '../util/file';
|
||||
import { Lazy, lazy } from '../util/lazy';
|
||||
import MDDocumentSymbolProvider from './documentSymbolProvider';
|
||||
import { SkinnyTextDocument, SkinnyTextLine } from '../tableOfContentsProvider';
|
||||
import { flatten } from '../util/arrays';
|
||||
|
||||
export interface WorkspaceMarkdownDocumentProvider {
|
||||
getAllMarkdownDocuments(): Thenable<Iterable<SkinnyTextDocument>>;
|
||||
|
||||
readonly onDidChangeMarkdownDocument: vscode.Event<SkinnyTextDocument>;
|
||||
readonly onDidCreateMarkdownDocument: vscode.Event<SkinnyTextDocument>;
|
||||
readonly onDidDeleteMarkdownDocument: vscode.Event<vscode.Uri>;
|
||||
}
|
||||
|
||||
class VSCodeWorkspaceMarkdownDocumentProvider extends Disposable implements WorkspaceMarkdownDocumentProvider {
|
||||
|
||||
private readonly _onDidChangeMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<SkinnyTextDocument>());
|
||||
private readonly _onDidCreateMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<SkinnyTextDocument>());
|
||||
private readonly _onDidDeleteMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<vscode.Uri>());
|
||||
|
||||
private _watcher: vscode.FileSystemWatcher | undefined;
|
||||
|
||||
async getAllMarkdownDocuments() {
|
||||
const resources = await vscode.workspace.findFiles('**/*.md', '**/node_modules/**');
|
||||
const docs = await Promise.all(resources.map(doc => this.getMarkdownDocument(doc)));
|
||||
return docs.filter(doc => !!doc) as SkinnyTextDocument[];
|
||||
}
|
||||
|
||||
public get onDidChangeMarkdownDocument() {
|
||||
this.ensureWatcher();
|
||||
return this._onDidChangeMarkdownDocumentEmitter.event;
|
||||
}
|
||||
|
||||
public get onDidCreateMarkdownDocument() {
|
||||
this.ensureWatcher();
|
||||
return this._onDidCreateMarkdownDocumentEmitter.event;
|
||||
}
|
||||
|
||||
public get onDidDeleteMarkdownDocument() {
|
||||
this.ensureWatcher();
|
||||
return this._onDidDeleteMarkdownDocumentEmitter.event;
|
||||
}
|
||||
|
||||
private ensureWatcher(): void {
|
||||
if (this._watcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._watcher = this._register(vscode.workspace.createFileSystemWatcher('**/*.md'));
|
||||
|
||||
this._watcher.onDidChange(async resource => {
|
||||
const document = await this.getMarkdownDocument(resource);
|
||||
if (document) {
|
||||
this._onDidChangeMarkdownDocumentEmitter.fire(document);
|
||||
}
|
||||
}, null, this._disposables);
|
||||
|
||||
this._watcher.onDidCreate(async resource => {
|
||||
const document = await this.getMarkdownDocument(resource);
|
||||
if (document) {
|
||||
this._onDidCreateMarkdownDocumentEmitter.fire(document);
|
||||
}
|
||||
}, null, this._disposables);
|
||||
|
||||
this._watcher.onDidDelete(async resource => {
|
||||
this._onDidDeleteMarkdownDocumentEmitter.fire(resource);
|
||||
}, null, this._disposables);
|
||||
|
||||
vscode.workspace.onDidChangeTextDocument(e => {
|
||||
if (isMarkdownFile(e.document)) {
|
||||
this._onDidChangeMarkdownDocumentEmitter.fire(e.document);
|
||||
}
|
||||
}, null, this._disposables);
|
||||
}
|
||||
|
||||
private async getMarkdownDocument(resource: vscode.Uri): Promise<SkinnyTextDocument | undefined> {
|
||||
const matchingDocuments = vscode.workspace.textDocuments.filter((doc) => doc.uri.toString() === resource.toString());
|
||||
if (matchingDocuments.length !== 0) {
|
||||
return matchingDocuments[0];
|
||||
}
|
||||
|
||||
const bytes = await vscode.workspace.fs.readFile(resource);
|
||||
|
||||
// We assume that markdown is in UTF-8
|
||||
const text = Buffer.from(bytes).toString('utf-8');
|
||||
|
||||
const lines: SkinnyTextLine[] = [];
|
||||
const parts = text.split(/(\r?\n)/);
|
||||
const lineCount = Math.floor(parts.length / 2) + 1;
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
lines.push({
|
||||
text: parts[line * 2]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
uri: resource,
|
||||
version: 0,
|
||||
lineCount: lineCount,
|
||||
lineAt: (index) => {
|
||||
return lines[index];
|
||||
},
|
||||
getText: () => {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default class MarkdownWorkspaceSymbolProvider extends Disposable implements vscode.WorkspaceSymbolProvider {
|
||||
private _symbolCache = new Map<string, Lazy<Thenable<vscode.SymbolInformation[]>>>();
|
||||
private _symbolCachePopulated: boolean = false;
|
||||
|
||||
public constructor(
|
||||
private _symbolProvider: MDDocumentSymbolProvider,
|
||||
private _workspaceMarkdownDocumentProvider: WorkspaceMarkdownDocumentProvider = new VSCodeWorkspaceMarkdownDocumentProvider()
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async provideWorkspaceSymbols(query: string): Promise<vscode.SymbolInformation[]> {
|
||||
if (!this._symbolCachePopulated) {
|
||||
await this.populateSymbolCache();
|
||||
this._symbolCachePopulated = true;
|
||||
|
||||
this._workspaceMarkdownDocumentProvider.onDidChangeMarkdownDocument(this.onDidChangeDocument, this, this._disposables);
|
||||
this._workspaceMarkdownDocumentProvider.onDidCreateMarkdownDocument(this.onDidChangeDocument, this, this._disposables);
|
||||
this._workspaceMarkdownDocumentProvider.onDidDeleteMarkdownDocument(this.onDidDeleteDocument, this, this._disposables);
|
||||
}
|
||||
|
||||
const allSymbolsSets = await Promise.all(Array.from(this._symbolCache.values()).map(x => x.value));
|
||||
const allSymbols = flatten(allSymbolsSets);
|
||||
return allSymbols.filter(symbolInformation => symbolInformation.name.toLowerCase().indexOf(query.toLowerCase()) !== -1);
|
||||
}
|
||||
|
||||
public async populateSymbolCache(): Promise<void> {
|
||||
const markdownDocumentUris = await this._workspaceMarkdownDocumentProvider.getAllMarkdownDocuments();
|
||||
for (const document of markdownDocumentUris) {
|
||||
this._symbolCache.set(document.uri.fsPath, this.getSymbols(document));
|
||||
}
|
||||
}
|
||||
|
||||
private getSymbols(document: SkinnyTextDocument): Lazy<Thenable<vscode.SymbolInformation[]>> {
|
||||
return lazy(async () => {
|
||||
return this._symbolProvider.provideDocumentSymbolInformation(document);
|
||||
});
|
||||
}
|
||||
|
||||
private onDidChangeDocument(document: SkinnyTextDocument) {
|
||||
this._symbolCache.set(document.uri.fsPath, this.getSymbols(document));
|
||||
}
|
||||
|
||||
private onDidDeleteDocument(resource: vscode.Uri) {
|
||||
this._symbolCache.delete(resource.fsPath);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user