Prototyping Markdown Preview Synchronization With Editors (#18762)

* Adds command to post a message to an html preview

**Bug**
There is currently no easy way to communicate with an html preview document after the preview has been created.

**Fix**
Adds a command called `vscode.htmlPreview.postMessage` to post a message to a visible html preview. This message will only be posted if the target preview is visible.

Inside the preview, the event is recieved using the standard dom event:

* Remove logging

* proto

Continue proto

* clean up rendering

* Gate prototype

* Fix gating

* Remove public command

* Change setting name

* Added current position indicator

* Reveal center
This commit is contained in:
Matt Bierner 2017-01-18 17:58:45 -08:00 committed by GitHub
parent 5dc2fb8c30
commit b29ef9b4e8
9 changed files with 244 additions and 52 deletions

View file

@ -5,16 +5,139 @@
'use strict';
let pageHeight = 0;
(function () {
/**
* Find the elements around line.
*
* If an exact match, returns a single element. If the line is between elements,
* returns the element before and the element after the given line.
*/
function getElementsAroundSourceLine(targetLine) {
const lines = document.getElementsByClassName('code-line');
let before = null;
for (const element of lines) {
const lineNumber = +element.getAttribute('data-line');
if (isNaN(lineNumber)) {
continue;
}
const entry = { line: lineNumber, element: element };
if (lineNumber === targetLine) {
return { before: entry, after: null };
} else if (lineNumber > targetLine) {
return { before, after: entry };
}
before = entry;
}
return { before };
}
window.onload = () => {
pageHeight = document.body.getBoundingClientRect().height;
};
function getSourceRevealAddedOffset() {
return -(window.innerHeight * 1 / 5);
}
window.addEventListener('resize', () => {
const currentOffset = window.scrollY;
const newPageHeight = document.body.getBoundingClientRect().height;
const dHeight = newPageHeight / pageHeight;
window.scrollTo(0, currentOffset * dHeight);
pageHeight = newPageHeight;
}, true);
/**
* Attempt to reveal the element for a source line in the editor.
*/
function scrollToRevealSourceLine(line) {
const {before, after} = getElementsAroundSourceLine(line);
marker.update(before && before.element);
if (before) {
let scrollTo = 0;
if (after) {
// Between two elements. Go to percentage offset between them.
const betweenProgress = (line - before.line) / (after.line - before.line);
const elementOffset = after.element.getBoundingClientRect().top - before.element.getBoundingClientRect().top;
scrollTo = before.element.getBoundingClientRect().top + betweenProgress * elementOffset;
} else {
scrollTo = before.element.getBoundingClientRect().top;
}
window.scroll(0, window.scrollY + scrollTo + getSourceRevealAddedOffset());
}
}
function didUpdateScrollPosition(offset) {
const lines = document.getElementsByClassName('code-line');
let nearest = lines[0];
for (let i = lines.length - 1; i >= 0; --i) {
const lineElement = lines[i];
if (offset <= window.scrollY + lineElement.getBoundingClientRect().top + lineElement.getBoundingClientRect().height) {
nearest = lineElement;
} else {
break;
}
}
if (nearest) {
const line = +nearest.getAttribute('data-line');
const args = [window.initialData.source, line];
window.parent.postMessage({
command: "did-click-link",
data: `command:_markdown.didClick?${encodeURIComponent(JSON.stringify(args))}`
}, "file://");
}
}
class ActiveLineMarker {
update(before) {
this._unmarkActiveElement(this._current);
this._markActiveElement(before);
this._current = before;
}
_unmarkActiveElement(element) {
if (!element) {
return;
}
element.className = element.className.replace(/\bcode-active-line\b/g);
}
_markActiveElement(element) {
if (!element) {
return;
}
element.className += ' code-active-line';
}
}
var pageHeight = 0;
var marker = new ActiveLineMarker();
window.onload = () => {
pageHeight = document.body.getBoundingClientRect().height;
if (window.initialData.enablePreviewSync) {
const initialLine = +window.initialData.line || 0;
scrollToRevealSourceLine(initialLine);
}
};
window.addEventListener('resize', () => {
const currentOffset = window.scrollY;
const newPageHeight = document.body.getBoundingClientRect().height;
const dHeight = newPageHeight / pageHeight;
window.scrollTo(0, currentOffset * dHeight);
pageHeight = newPageHeight;
}, true);
if (window.initialData.enablePreviewSync) {
window.addEventListener('message', event => {
const line = +event.data.line;
if (!isNaN(line)) {
scrollToRevealSourceLine(line);
}
}, false);
document.ondblclick = (e) => {
const offset = e.pageY;
didUpdateScrollPosition(offset);
};
/*
window.onscroll = () => {
didUpdateScrollPosition(window.scrollY);
};
*/
}
}());

View file

@ -15,6 +15,20 @@ body.scrollBeyondLastLine {
margin-bottom: calc(100vh - 22px);
}
.code-active-line {
position: relative;
}
.code-active-line:before {
content: "";
display: block;
position: absolute;
top: 0;
left: -12px;
height: 100%;
border-left: 3px solid #4080D0;
}
img {
max-width: 100%;
max-height: 100%;

View file

@ -145,6 +145,11 @@
"type": "number",
"default": 1.6,
"description": "%markdown.preview.lineHeight.desc%"
},
"markdown.preview.experimentalSyncronizationEnabled": {
"type": "boolean",
"default": true,
"description": "%markdown.preview.experimentalSyncronizationEnabled.desc%"
}
}
}

View file

@ -6,5 +6,6 @@
"markdown.previewFrontMatter.dec": "Sets how YAML front matter should be rendered in the markdown preview. 'hide' removes the front matter. Otherwise, the front matter is treated as markdown content.",
"markdown.preview.fontFamily.desc": "Controls the font family used in the markdown preview.",
"markdown.preview.fontSize.desc": "Controls the font size in pixels used in the markdown preview.",
"markdown.preview.lineHeight.desc": "Controls the line height used in the markdown preview. This number is relative to the font size."
"markdown.preview.lineHeight.desc": "Controls the line height used in the markdown preview. This number is relative to the font size.",
"markdown.preview.experimentalSyncronizationEnabled.desc": "Enable experimental syncronization between the markdown preview and the editor"
}

View file

@ -29,6 +29,12 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(vscode.commands.registerCommand('markdown.showPreviewToSide', uri => showPreview(uri, true)));
context.subscriptions.push(vscode.commands.registerCommand('markdown.showSource', showSource));
context.subscriptions.push(vscode.commands.registerCommand('_markdown.didClick', (uri, line) => {
return vscode.workspace.openTextDocument(vscode.Uri.parse(decodeURIComponent(uri)))
.then(document => vscode.window.showTextDocument(document))
.then(editor => vscode.commands.executeCommand('revealLine', { lineNumber: line, at: 'center' }));
}));
context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(document => {
if (isMarkdownFile(document)) {
const uri = getMarkdownUri(document.uri);
@ -51,6 +57,16 @@ export function activate(context: vscode.ExtensionContext) {
}
});
}));
context.subscriptions.push(vscode.window.onDidChangeTextEditorSelection(event => {
if (isMarkdownFile(event.textEditor.document)) {
vscode.commands.executeCommand('_workbench.htmlPreview.postMessage',
getMarkdownUri(event.textEditor.document.uri),
{
line: event.selections[0].start.line
});
}
}));
}
function isMarkdownFile(document: vscode.TextDocument) {
@ -152,13 +168,11 @@ interface IRenderer {
}
class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
private _context: vscode.ExtensionContext;
private _onDidChange = new vscode.EventEmitter<vscode.Uri>();
private _waiting: boolean;
private _renderer: IRenderer;
constructor(context: vscode.ExtensionContext) {
this._context = context;
constructor(private context: vscode.ExtensionContext) {
this._waiting = false;
this._renderer = this.createRenderer();
}
@ -197,12 +211,13 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
md.renderer.rules.paragraph_open = createLineNumberRenderer('paragraph_open');
md.renderer.rules.heading_open = createLineNumberRenderer('heading_open');
md.renderer.rules.image = createLineNumberRenderer('image');
md.renderer.rules.code_block = createLineNumberRenderer('code_block');
return md;
}
private getMediaPath(mediaFile: string): string {
return this._context.asAbsolutePath(path.join('media', mediaFile));
return this.context.asAbsolutePath(path.join('media', mediaFile));
}
private isAbsolute(p: string): boolean {
@ -249,14 +264,13 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
return '';
}
const {fontFamily, fontSize, lineHeight} = previewSettings;
return [
'<style>',
'body {',
fontFamily ? `font-family: ${fontFamily};` : '',
+fontSize > 0 ? `font-size: ${fontSize}px;` : '',
+lineHeight > 0 ? `line-height: ${lineHeight};` : '',
'}',
'</style>'].join('\n');
return `<style>
body {
${fontFamily ? `font-family: ${fontFamily};` : ''}
${+fontSize > 0 ? `font-size: ${fontSize}px;` : ''}
${+lineHeight > 0 ? `line-height: ${lineHeight};` : ''}
}
</style>`;
}
public provideTextDocumentContent(uri: vscode.Uri): Thenable<string> {
@ -264,29 +278,36 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
return vscode.workspace.openTextDocument(sourceUri).then(document => {
const scrollBeyondLastLine = vscode.workspace.getConfiguration('editor')['scrollBeyondLastLine'];
const wordWrap = vscode.workspace.getConfiguration('editor')['wordWrap'];
const enablePreviewSync = vscode.workspace.getConfiguration('markdown').get('preview.experimentalSyncronizationEnabled', true);
const head = ([] as Array<string>).concat(
'<!DOCTYPE html>',
'<html>',
'<head>',
'<meta http-equiv="Content-type" content="text/html;charset=UTF-8">',
`<link rel="stylesheet" type="text/css" href="${this.getMediaPath('markdown.css')}" >`,
`<link rel="stylesheet" type="text/css" href="${this.getMediaPath('tomorrow.css')}" >`,
this.getSettingsOverrideStyles(),
this.computeCustomStyleSheetIncludes(uri),
`<base href="${document.uri.toString(true)}">`,
'</head>',
`<body class="${scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${wordWrap ? 'wordWrap' : ''}">`
).join('\n');
const body = this._renderer.render(this.getDocumentContentForPreview(document));
let initialLine = 0;
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.uri.path === sourceUri.path) {
initialLine = editor.selection.start.line;
}
const tail = [
`<script src="${this.getMediaPath('main.js')}"></script>`,
'</body>',
'</html>'
].join('\n');
return head + body + tail;
return `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<link rel="stylesheet" type="text/css" href="${this.getMediaPath('markdown.css')}">
<link rel="stylesheet" type="text/css" href="${this.getMediaPath('tomorrow.css')}">
${this.getSettingsOverrideStyles()}
${this.computeCustomStyleSheetIncludes(uri)}
<base href="${document.uri.toString(true)}">
</head>
<body class="${scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${wordWrap ? 'wordWrap' : ''}">
${this._renderer.render(this.getDocumentContentForPreview(document))}
<script>
window.initialData = {
source: "${encodeURIComponent(sourceUri.scheme + '://' + sourceUri.path)}",
line: ${initialLine},
enablePreviewSync: ${!!enablePreviewSync}
};
</script>
<script src="${this.getMediaPath('main.js')}"></script>
</body>
</html>`;
});
}

View file

@ -100,3 +100,15 @@ CommandsRegistry.registerCommand('_workbench.previewHtml', function (accessor: S
.openEditor(input, { pinned: true }, position)
.then(editor => true);
});
CommandsRegistry.registerCommand('_workbench.htmlPreview.postMessage', (accessor: ServicesAccessor, resource: URI | string, message: any) => {
const uri = resource instanceof URI ? resource : URI.parse(resource);
const activePreviews = accessor.get(IWorkbenchEditorService).getVisibleEditors()
.filter(c => c instanceof HtmlPreviewPart)
.map(e => e as HtmlPreviewPart)
.filter(e => e.model.uri.scheme === uri.scheme && e.model.uri.path === uri.path);
for (const preview of activePreviews) {
preview.sendMessage(message);
}
return activePreviews.length > 0;
});

View file

@ -21,6 +21,8 @@ import { HtmlInput } from 'vs/workbench/parts/html/common/htmlInput';
import { IThemeService } from 'vs/workbench/services/themes/common/themeService';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { ITextModelResolverService, ITextEditorModel } from 'vs/editor/common/services/resolverService';
import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService';
import Webview from './webview';
/**
@ -40,7 +42,7 @@ export class HtmlPreviewPart extends BaseEditor {
private _baseUrl: URI;
private _modelRef: IReference<ITextEditorModel>;
private get _model(): IModel { return this._modelRef.object.textEditorModel; }
public get model(): IModel { return this._modelRef.object.textEditorModel; }
private _modelChangeSubscription = EmptyDisposable;
private _themeChangeSubscription = EmptyDisposable;
@ -117,14 +119,14 @@ export class HtmlPreviewPart extends BaseEditor {
this.webview.style(this._themeService.getColorTheme());
if (this._hasValidModel()) {
this._modelChangeSubscription = this._model.onDidChangeContent(() => this.webview.contents = this._model.getLinesContent());
this.webview.contents = this._model.getLinesContent();
this._modelChangeSubscription = this.model.onDidChangeContent(() => this.webview.contents = this.model.getLinesContent());
this.webview.contents = this.model.getLinesContent();
}
}
}
private _hasValidModel(): boolean {
return this._modelRef && this._model && !this._model.isDisposed();
return this._modelRef && this.model && !this.model.isDisposed();
}
public layout(dimension: Dimension): void {
@ -144,6 +146,10 @@ export class HtmlPreviewPart extends BaseEditor {
super.clearInput();
}
public sendMessage(data: any): void {
this.webview.sendMessage(data);
}
public setInput(input: EditorInput, options?: EditorOptions): TPromise<void> {
if (this.input && this.input.matches(input) && this._hasValidModel()) {
@ -168,13 +174,13 @@ export class HtmlPreviewPart extends BaseEditor {
this._modelRef = ref;
}
if (!this._model) {
if (!this.model) {
return TPromise.wrapError<void>(localize('html.voidInput', "Invalid editor input."));
}
this._modelChangeSubscription = this._model.onDidChangeContent(() => this.webview.contents = this._model.getLinesContent());
this._modelChangeSubscription = this.model.onDidChangeContent(() => this.webview.contents = this.model.getLinesContent());
this.webview.baseUrl = resourceUri.toString(true);
this.webview.contents = this._model.getLinesContent();
this.webview.contents = this.model.getLinesContent();
});
});
}

View file

@ -128,6 +128,12 @@ document.addEventListener("DOMContentLoaded", function (event) {
ipcRenderer.sendToHost('did-set-content', stats);
});
// Forward message to the embedded iframe
ipcRenderer.on('message', function (event, data) {
const target = getTarget();
target.contentWindow.postMessage(data, 'file://');
});
// forward messages from the embedded iframe
window.onmessage = function (message) {
ipcRenderer.sendToHost(message.data.command, message.data.data);

View file

@ -158,6 +158,10 @@ export default class Webview {
this._send('focus');
}
public sendMessage(data: any): void {
this._send('message', data);
}
style(theme: IColorTheme): void {
let themeId = theme.id;
const {color, backgroundColor, fontFamily, fontWeight, fontSize} = window.getComputedStyle(this._styleElement);