Refresh Images In Markdown Preview On Change (#114083)
* Refresh Images In Markdown Preview On Change (implements #65258). * Fixes tests. * Implements etags. * Adds tests for urlToUri. * Updates doc comment.pull/114618/head
parent
686cd7df53
commit
1f8643ef76
|
@ -15,6 +15,6 @@ export class RenderDocument implements Command {
|
|||
) { }
|
||||
|
||||
public async execute(document: SkinnyTextDocument | string): Promise<string> {
|
||||
return this.engine.render(document);
|
||||
return (await (this.engine.render(document))).html;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,8 +14,9 @@ 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 { MarkdownContentProvider, MarkdownContentProviderOutput } from './previewContentProvider';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { urlToUri } from '../util/url';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
|
@ -118,6 +119,8 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
|
|||
private _disposed: boolean = false;
|
||||
private imageInfo: { readonly id: string, readonly width: number, readonly height: number; }[] = [];
|
||||
|
||||
private readonly _fileWatchersBySrc = new Map</* src: */ string, vscode.FileSystemWatcher>();
|
||||
|
||||
constructor(
|
||||
webview: vscode.WebviewPanel,
|
||||
resource: vscode.Uri,
|
||||
|
@ -208,6 +211,9 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
|
|||
super.dispose();
|
||||
this._disposed = true;
|
||||
clearTimeout(this.throttleTimer);
|
||||
for (const entry of this._fileWatchersBySrc.values()) {
|
||||
entry.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public get resource(): vscode.Uri {
|
||||
|
@ -224,6 +230,10 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The first call immediately refreshes the preview,
|
||||
* calls happening shortly thereafter are debounced.
|
||||
*/
|
||||
public refresh() {
|
||||
// Schedule update if none is pending
|
||||
if (!this.throttleTimer) {
|
||||
|
@ -360,7 +370,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
|
|||
this._webviewPanel.webview.html = this._contentProvider.provideFileNotFoundContent(this._resource);
|
||||
}
|
||||
|
||||
private setContent(html: string): void {
|
||||
private setContent(content: MarkdownContentProviderOutput): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
@ -371,7 +381,30 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
|
|||
this._webviewPanel.iconPath = this.iconPath;
|
||||
this._webviewPanel.webview.options = this.getWebviewOptions();
|
||||
|
||||
this._webviewPanel.webview.html = html;
|
||||
this._webviewPanel.webview.html = content.html;
|
||||
|
||||
const srcs = new Set(content.containingImages.map(img => img.src));
|
||||
|
||||
// Delete stale file watchers.
|
||||
for (const [src, watcher] of [...this._fileWatchersBySrc]) {
|
||||
if (!srcs.has(src)) {
|
||||
watcher.dispose();
|
||||
this._fileWatchersBySrc.delete(src);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new file watchers.
|
||||
const root = vscode.Uri.joinPath(this._resource, '../');
|
||||
for (const src of srcs) {
|
||||
const uri = urlToUri(src, root);
|
||||
if (uri && uri.scheme === 'file' && !this._fileWatchersBySrc.has(src)) {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(uri.fsPath);
|
||||
watcher.onDidChange(() => {
|
||||
this.refresh();
|
||||
});
|
||||
this._fileWatchersBySrc.set(src, watcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getWebviewOptions(): vscode.WebviewOptions {
|
||||
|
|
|
@ -39,6 +39,12 @@ function escapeAttribute(value: string | vscode.Uri): string {
|
|||
return value.toString().replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export interface MarkdownContentProviderOutput {
|
||||
html: string;
|
||||
containingImages: { src: string }[];
|
||||
}
|
||||
|
||||
|
||||
export class MarkdownContentProvider {
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine,
|
||||
|
@ -54,7 +60,7 @@ export class MarkdownContentProvider {
|
|||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
initialLine: number | undefined = undefined,
|
||||
state?: any
|
||||
): Promise<string> {
|
||||
): Promise<MarkdownContentProviderOutput> {
|
||||
const sourceUri = markdownDocument.uri;
|
||||
const config = previewConfigurations.loadAndCacheConfiguration(sourceUri);
|
||||
const initialData = {
|
||||
|
@ -75,7 +81,7 @@ export class MarkdownContentProvider {
|
|||
const csp = this.getCsp(resourceProvider, sourceUri, nonce);
|
||||
|
||||
const body = await this.engine.render(markdownDocument);
|
||||
return `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html style="${escapeAttribute(this.getSettingsOverrideStyles(config))}">
|
||||
<head>
|
||||
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
||||
|
@ -89,11 +95,15 @@ export class MarkdownContentProvider {
|
|||
<base href="${resourceProvider.asWebviewUri(markdownDocument.uri)}">
|
||||
</head>
|
||||
<body class="vscode-body ${config.scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${config.wordWrap ? 'wordWrap' : ''} ${config.markEditorSelection ? 'showEditorSelection' : ''}">
|
||||
${body}
|
||||
${body.html}
|
||||
<div class="code-line" data-line="${markdownDocument.lineCount}"></div>
|
||||
${this.getScripts(resourceProvider, nonce)}
|
||||
</body>
|
||||
</html>`;
|
||||
return {
|
||||
html,
|
||||
containingImages: body.containingImages,
|
||||
};
|
||||
}
|
||||
|
||||
public provideFileNotFoundContent(
|
||||
|
|
|
@ -54,6 +54,15 @@ class TokenCache {
|
|||
}
|
||||
}
|
||||
|
||||
export interface RenderOutput {
|
||||
html: string;
|
||||
containingImages: { src: string }[];
|
||||
}
|
||||
|
||||
interface RenderEnv {
|
||||
containingImages: { src: string }[];
|
||||
}
|
||||
|
||||
export class MarkdownEngine {
|
||||
private md?: Promise<MarkdownIt>;
|
||||
|
||||
|
@ -141,7 +150,7 @@ export class MarkdownEngine {
|
|||
return engine.parse(text.replace(UNICODE_NEWLINE_REGEX, ''), {});
|
||||
}
|
||||
|
||||
public async render(input: SkinnyTextDocument | string): Promise<string> {
|
||||
public async render(input: SkinnyTextDocument | string): Promise<RenderOutput> {
|
||||
const config = this.getConfig(typeof input === 'string' ? undefined : input.uri);
|
||||
const engine = await this.getEngine(config);
|
||||
|
||||
|
@ -149,10 +158,19 @@ export class MarkdownEngine {
|
|||
? this.tokenizeString(input, engine)
|
||||
: this.tokenizeDocument(input, config, engine);
|
||||
|
||||
return engine.renderer.render(tokens, {
|
||||
const env: RenderEnv = {
|
||||
containingImages: []
|
||||
};
|
||||
|
||||
const html = engine.renderer.render(tokens, {
|
||||
...(engine as any).options,
|
||||
...config
|
||||
}, {});
|
||||
}, env);
|
||||
|
||||
return {
|
||||
html,
|
||||
containingImages: env.containingImages
|
||||
};
|
||||
}
|
||||
|
||||
public async parse(document: SkinnyTextDocument): Promise<Token[]> {
|
||||
|
@ -192,12 +210,13 @@ export class MarkdownEngine {
|
|||
|
||||
private addImageStabilizer(md: any): void {
|
||||
const original = md.renderer.rules.image;
|
||||
md.renderer.rules.image = (tokens: any, idx: number, options: any, env: any, self: any) => {
|
||||
md.renderer.rules.image = (tokens: any, idx: number, options: any, env: RenderEnv, self: any) => {
|
||||
const token = tokens[idx];
|
||||
token.attrJoin('class', 'loading');
|
||||
|
||||
const src = token.attrGet('src');
|
||||
if (src) {
|
||||
env.containingImages.push({ src });
|
||||
const imgHash = hash(src);
|
||||
token.attrSet('id', `image-hash-${imgHash}`);
|
||||
}
|
||||
|
|
|
@ -21,12 +21,31 @@ suite('markdown.engine', () => {
|
|||
test('Renders a document', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, input);
|
||||
const engine = createNewMarkdownEngine();
|
||||
assert.strictEqual(await engine.render(doc), output);
|
||||
assert.strictEqual((await engine.render(doc)).html, output);
|
||||
});
|
||||
|
||||
test('Renders a string', async () => {
|
||||
const engine = createNewMarkdownEngine();
|
||||
assert.strictEqual(await engine.render(input), output);
|
||||
assert.strictEqual((await engine.render(input)).html, output);
|
||||
});
|
||||
});
|
||||
|
||||
suite('image-caching', () => {
|
||||
const input = '![](img.png) [](no-img.png) ![](http://example.org/img.png) ![](img.png) ![](./img2.png)';
|
||||
|
||||
test('Extracts all images', async () => {
|
||||
const engine = createNewMarkdownEngine();
|
||||
assert.deepStrictEqual((await engine.render(input)), {
|
||||
html: '<p data-line="0" class="code-line">'
|
||||
+ '<img src="img.png" alt="" class="loading" id="image-hash--754511435"> '
|
||||
+ '<a href="no-img.png" data-href="no-img.png"></a> '
|
||||
+ '<img src="http://example.org/img.png" alt="" class="loading" id="image-hash--1903814170"> '
|
||||
+ '<img src="img.png" alt="" class="loading" id="image-hash--754511435"> '
|
||||
+ '<img src="./img2.png" alt="" class="loading" id="image-hash-265238964">'
|
||||
+ '</p>\n'
|
||||
,
|
||||
containingImages: [{ src: 'img.png' }, { src: 'http://example.org/img.png' }, { src: 'img.png' }, { src: './img2.png' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { deepStrictEqual } from 'assert';
|
||||
import 'mocha';
|
||||
import { Uri } from 'vscode';
|
||||
import { urlToUri } from '../util/url';
|
||||
|
||||
suite('urlToUri', () => {
|
||||
test('Absolute File', () => {
|
||||
deepStrictEqual(
|
||||
urlToUri('file:///root/test.txt', Uri.parse('file:///usr/home/')),
|
||||
Uri.parse('file:///root/test.txt')
|
||||
);
|
||||
});
|
||||
|
||||
test('Relative File', () => {
|
||||
deepStrictEqual(
|
||||
urlToUri('./file.ext', Uri.parse('file:///usr/home/')),
|
||||
Uri.parse('file:///usr/home/file.ext')
|
||||
);
|
||||
});
|
||||
|
||||
test('Http Basic', () => {
|
||||
deepStrictEqual(
|
||||
urlToUri('http://example.org?q=10&f', Uri.parse('file:///usr/home/')),
|
||||
Uri.parse('http://example.org?q=10&f')
|
||||
);
|
||||
});
|
||||
|
||||
test('Http Encoded Chars', () => {
|
||||
deepStrictEqual(
|
||||
urlToUri('http://example.org/%C3%A4', Uri.parse('file:///usr/home/')),
|
||||
Uri.parse('http://example.org/%C3%A4')
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
|
||||
declare const URL: typeof import('url').URL;
|
||||
|
||||
/**
|
||||
* Tries to convert an url into a vscode uri and returns undefined if this is not possible.
|
||||
* `url` can be absolute or relative.
|
||||
*/
|
||||
export function urlToUri(url: string, base: vscode.Uri): vscode.Uri | undefined {
|
||||
try {
|
||||
// `vscode.Uri.joinPath` cannot be used, since it understands
|
||||
// `src` as path, not as relative url. This is problematic for query args.
|
||||
const parsedUrl = new URL(url, base.toString());
|
||||
const uri = vscode.Uri.parse(parsedUrl.toString());
|
||||
return uri;
|
||||
} catch (e) {
|
||||
// Don't crash if `URL` cannot parse `src`.
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -25,7 +25,8 @@ export namespace WebviewResourceResponse {
|
|||
|
||||
constructor(
|
||||
public readonly stream: VSBufferReadableStream,
|
||||
public readonly mimeType: string
|
||||
public readonly etag: string | undefined,
|
||||
public readonly mimeType: string,
|
||||
) { }
|
||||
}
|
||||
|
||||
|
@ -36,7 +37,7 @@ export namespace WebviewResourceResponse {
|
|||
}
|
||||
|
||||
interface FileReader {
|
||||
readFileStream(resource: URI): Promise<VSBufferReadableStream>;
|
||||
readFileStream(resource: URI): Promise<{ stream: VSBufferReadableStream, etag?: string }>;
|
||||
}
|
||||
|
||||
export async function loadLocalResource(
|
||||
|
@ -73,7 +74,7 @@ export async function loadLocalResource(
|
|||
logService.debug(`loadLocalResource - Loaded over http(s). requestUri=${requestUri}, response=${response.res.statusCode}`);
|
||||
|
||||
if (response.res.statusCode === 200) {
|
||||
return new WebviewResourceResponse.StreamSuccess(response.stream, mime);
|
||||
return new WebviewResourceResponse.StreamSuccess(response.stream, undefined, mime);
|
||||
}
|
||||
return WebviewResourceResponse.Failed;
|
||||
}
|
||||
|
@ -82,7 +83,7 @@ export async function loadLocalResource(
|
|||
const contents = await fileReader.readFileStream(resourceToLoad);
|
||||
logService.debug(`loadLocalResource - Loaded using fileReader. requestUri=${requestUri}`);
|
||||
|
||||
return new WebviewResourceResponse.StreamSuccess(contents, mime);
|
||||
return new WebviewResourceResponse.StreamSuccess(contents.stream, contents.etag, mime);
|
||||
} catch (err) {
|
||||
logService.debug(`loadLocalResource - Error using fileReader. requestUri=${requestUri}`);
|
||||
console.log(err);
|
||||
|
|
|
@ -175,10 +175,14 @@ export class WebviewProtocolProvider extends Disposable {
|
|||
};
|
||||
}
|
||||
|
||||
const fileService = {
|
||||
readFileStream: async (resource: URI): Promise<VSBufferReadableStream> => {
|
||||
const fileReader = {
|
||||
readFileStream: async (resource: URI): Promise<{ stream: VSBufferReadableStream, etag?: string }> => {
|
||||
if (resource.scheme === Schemas.file) {
|
||||
return (await this.fileService.readFileStream(resource)).value;
|
||||
const result = (await this.fileService.readFileStream(resource));
|
||||
return {
|
||||
stream: result.value,
|
||||
etag: result.etag
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown uri scheme. Try delegating the file read back to the renderer
|
||||
|
@ -201,7 +205,7 @@ export class WebviewProtocolProvider extends Disposable {
|
|||
throw new FileOperationError('Could not read file', FileOperationResult.FILE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return bufferToStream(result);
|
||||
return { stream: bufferToStream(result), etag: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -210,15 +214,41 @@ export class WebviewProtocolProvider extends Disposable {
|
|||
roots: metadata.localResourceRoots,
|
||||
remoteConnectionData: metadata.remoteConnectionData,
|
||||
rewriteUri,
|
||||
}, fileService, this.requestService, this.logService);
|
||||
}, fileReader, this.requestService, this.logService);
|
||||
|
||||
if (result.type === WebviewResourceResponse.Type.Success) {
|
||||
const cacheHeaders: Record<string, string> = result.etag ? {
|
||||
'ETag': result.etag,
|
||||
'Cache-Control': 'no-cache'
|
||||
} : {};
|
||||
|
||||
const ifNoneMatch = request.headers['If-None-Match'];
|
||||
if (ifNoneMatch && result.etag === ifNoneMatch) {
|
||||
/*
|
||||
* Note that the server generating a 304 response MUST
|
||||
* generate any of the following header fields that would
|
||||
* have been sent in a 200 (OK) response to the same request:
|
||||
* Cache-Control, Content-Location, Date, ETag, Expires, and Vary.
|
||||
* (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
|
||||
*/
|
||||
return callback({
|
||||
statusCode: 304, // not modified
|
||||
data: undefined, // The request fails if `data` is not set
|
||||
headers: {
|
||||
'Content-Type': result.mimeType,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
...cacheHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return callback({
|
||||
statusCode: 200,
|
||||
data: this.streamToNodeReadable(result.stream),
|
||||
headers: {
|
||||
'Content-Type': result.mimeType,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
...cacheHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -202,7 +202,7 @@ export class IFrameWebview extends BaseWebview<HTMLIFrameElement> implements Web
|
|||
remoteConnectionData,
|
||||
rewriteUri,
|
||||
}, {
|
||||
readFileStream: (resource) => this.fileService.readFileStream(resource).then(x => x.value),
|
||||
readFileStream: (resource) => this.fileService.readFileStream(resource).then(x => ({ stream: x.value, etag: x.etag })),
|
||||
}, this.requestService, this.logService);
|
||||
|
||||
if (result.type === WebviewResourceResponse.Type.Success) {
|
||||
|
|
|
@ -111,7 +111,7 @@ export class WebviewResourceRequestManager extends Disposable {
|
|||
roots: this._localResourceRoots,
|
||||
remoteConnectionData: remoteConnectionData,
|
||||
}, {
|
||||
readFileStream: (resource) => fileService.readFileStream(resource).then(x => x.value),
|
||||
readFileStream: (resource) => fileService.readFileStream(resource).then(x => ({ stream: x.value, etag: x.etag })),
|
||||
}, requestService, this._logService);
|
||||
this._logService.debug(`WebviewResourceRequestManager(${this.id}): finished resource load. uri: ${uri}, type=${response.type}`);
|
||||
|
||||
|
|
Loading…
Reference in New Issue