提交 b29ef9b4 编写于 作者: M Matt Bierner 提交者: GitHub

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
上级 5dc2fb8c
...@@ -5,16 +5,139 @@ ...@@ -5,16 +5,139 @@
'use strict'; 'use strict';
let pageHeight = 0; (function () {
/**
window.onload = () => { * Find the elements around line.
pageHeight = document.body.getBoundingClientRect().height; *
}; * 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.
window.addEventListener('resize', () => { */
const currentOffset = window.scrollY; function getElementsAroundSourceLine(targetLine) {
const newPageHeight = document.body.getBoundingClientRect().height; const lines = document.getElementsByClassName('code-line');
const dHeight = newPageHeight / pageHeight; let before = null;
window.scrollTo(0, currentOffset * dHeight); for (const element of lines) {
pageHeight = newPageHeight; const lineNumber = +element.getAttribute('data-line');
}, true); 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 };
}
function getSourceRevealAddedOffset() {
return -(window.innerHeight * 1 / 5);
}
/**
* 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);
};
*/
}
}());
\ No newline at end of file
...@@ -15,6 +15,20 @@ body.scrollBeyondLastLine { ...@@ -15,6 +15,20 @@ body.scrollBeyondLastLine {
margin-bottom: calc(100vh - 22px); 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 { img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
......
...@@ -145,6 +145,11 @@ ...@@ -145,6 +145,11 @@
"type": "number", "type": "number",
"default": 1.6, "default": 1.6,
"description": "%markdown.preview.lineHeight.desc%" "description": "%markdown.preview.lineHeight.desc%"
},
"markdown.preview.experimentalSyncronizationEnabled": {
"type": "boolean",
"default": true,
"description": "%markdown.preview.experimentalSyncronizationEnabled.desc%"
} }
} }
} }
......
...@@ -6,5 +6,6 @@ ...@@ -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.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.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.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"
} }
\ No newline at end of file
...@@ -29,6 +29,12 @@ export function activate(context: vscode.ExtensionContext) { ...@@ -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.showPreviewToSide', uri => showPreview(uri, true)));
context.subscriptions.push(vscode.commands.registerCommand('markdown.showSource', showSource)); 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 => { context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(document => {
if (isMarkdownFile(document)) { if (isMarkdownFile(document)) {
const uri = getMarkdownUri(document.uri); const uri = getMarkdownUri(document.uri);
...@@ -51,6 +57,16 @@ export function activate(context: vscode.ExtensionContext) { ...@@ -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) { function isMarkdownFile(document: vscode.TextDocument) {
...@@ -152,13 +168,11 @@ interface IRenderer { ...@@ -152,13 +168,11 @@ interface IRenderer {
} }
class MDDocumentContentProvider implements vscode.TextDocumentContentProvider { class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
private _context: vscode.ExtensionContext;
private _onDidChange = new vscode.EventEmitter<vscode.Uri>(); private _onDidChange = new vscode.EventEmitter<vscode.Uri>();
private _waiting: boolean; private _waiting: boolean;
private _renderer: IRenderer; private _renderer: IRenderer;
constructor(context: vscode.ExtensionContext) { constructor(private context: vscode.ExtensionContext) {
this._context = context;
this._waiting = false; this._waiting = false;
this._renderer = this.createRenderer(); this._renderer = this.createRenderer();
} }
...@@ -197,12 +211,13 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider { ...@@ -197,12 +211,13 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
md.renderer.rules.paragraph_open = createLineNumberRenderer('paragraph_open'); md.renderer.rules.paragraph_open = createLineNumberRenderer('paragraph_open');
md.renderer.rules.heading_open = createLineNumberRenderer('heading_open'); md.renderer.rules.heading_open = createLineNumberRenderer('heading_open');
md.renderer.rules.image = createLineNumberRenderer('image'); md.renderer.rules.image = createLineNumberRenderer('image');
md.renderer.rules.code_block = createLineNumberRenderer('code_block');
return md; return md;
} }
private getMediaPath(mediaFile: string): string { 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 { private isAbsolute(p: string): boolean {
...@@ -249,14 +264,13 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider { ...@@ -249,14 +264,13 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
return ''; return '';
} }
const {fontFamily, fontSize, lineHeight} = previewSettings; const {fontFamily, fontSize, lineHeight} = previewSettings;
return [ return `<style>
'<style>', body {
'body {', ${fontFamily ? `font-family: ${fontFamily};` : ''}
fontFamily ? `font-family: ${fontFamily};` : '', ${+fontSize > 0 ? `font-size: ${fontSize}px;` : ''}
+fontSize > 0 ? `font-size: ${fontSize}px;` : '', ${+lineHeight > 0 ? `line-height: ${lineHeight};` : ''}
+lineHeight > 0 ? `line-height: ${lineHeight};` : '', }
'}', </style>`;
'</style>'].join('\n');
} }
public provideTextDocumentContent(uri: vscode.Uri): Thenable<string> { public provideTextDocumentContent(uri: vscode.Uri): Thenable<string> {
...@@ -264,29 +278,36 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider { ...@@ -264,29 +278,36 @@ class MDDocumentContentProvider implements vscode.TextDocumentContentProvider {
return vscode.workspace.openTextDocument(sourceUri).then(document => { return vscode.workspace.openTextDocument(sourceUri).then(document => {
const scrollBeyondLastLine = vscode.workspace.getConfiguration('editor')['scrollBeyondLastLine']; const scrollBeyondLastLine = vscode.workspace.getConfiguration('editor')['scrollBeyondLastLine'];
const wordWrap = vscode.workspace.getConfiguration('editor')['wordWrap']; const wordWrap = vscode.workspace.getConfiguration('editor')['wordWrap'];
const enablePreviewSync = vscode.workspace.getConfiguration('markdown').get('preview.experimentalSyncronizationEnabled', true);
let initialLine = 0;
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.uri.path === sourceUri.path) {
initialLine = editor.selection.start.line;
}
const head = ([] as Array<string>).concat( return `<!DOCTYPE html>
'<!DOCTYPE html>', <html>
'<html>', <head>
'<head>', <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
'<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('markdown.css')}" >`, <link rel="stylesheet" type="text/css" href="${this.getMediaPath('tomorrow.css')}">
`<link rel="stylesheet" type="text/css" href="${this.getMediaPath('tomorrow.css')}" >`, ${this.getSettingsOverrideStyles()}
this.getSettingsOverrideStyles(), ${this.computeCustomStyleSheetIncludes(uri)}
this.computeCustomStyleSheetIncludes(uri), <base href="${document.uri.toString(true)}">
`<base href="${document.uri.toString(true)}">`, </head>
'</head>', <body class="${scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${wordWrap ? 'wordWrap' : ''}">
`<body class="${scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${wordWrap ? 'wordWrap' : ''}">` ${this._renderer.render(this.getDocumentContentForPreview(document))}
).join('\n'); <script>
const body = this._renderer.render(this.getDocumentContentForPreview(document)); window.initialData = {
source: "${encodeURIComponent(sourceUri.scheme + '://' + sourceUri.path)}",
const tail = [ line: ${initialLine},
`<script src="${this.getMediaPath('main.js')}"></script>`, enablePreviewSync: ${!!enablePreviewSync}
'</body>', };
'</html>' </script>
].join('\n'); <script src="${this.getMediaPath('main.js')}"></script>
</body>
return head + body + tail; </html>`;
}); });
} }
......
...@@ -100,3 +100,15 @@ CommandsRegistry.registerCommand('_workbench.previewHtml', function (accessor: S ...@@ -100,3 +100,15 @@ CommandsRegistry.registerCommand('_workbench.previewHtml', function (accessor: S
.openEditor(input, { pinned: true }, position) .openEditor(input, { pinned: true }, position)
.then(editor => true); .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;
});
...@@ -21,6 +21,8 @@ import { HtmlInput } from 'vs/workbench/parts/html/common/htmlInput'; ...@@ -21,6 +21,8 @@ import { HtmlInput } from 'vs/workbench/parts/html/common/htmlInput';
import { IThemeService } from 'vs/workbench/services/themes/common/themeService'; import { IThemeService } from 'vs/workbench/services/themes/common/themeService';
import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IOpenerService } from 'vs/platform/opener/common/opener';
import { ITextModelResolverService, ITextEditorModel } from 'vs/editor/common/services/resolverService'; import { ITextModelResolverService, ITextEditorModel } from 'vs/editor/common/services/resolverService';
import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService';
import Webview from './webview'; import Webview from './webview';
/** /**
...@@ -40,7 +42,7 @@ export class HtmlPreviewPart extends BaseEditor { ...@@ -40,7 +42,7 @@ export class HtmlPreviewPart extends BaseEditor {
private _baseUrl: URI; private _baseUrl: URI;
private _modelRef: IReference<ITextEditorModel>; 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 _modelChangeSubscription = EmptyDisposable;
private _themeChangeSubscription = EmptyDisposable; private _themeChangeSubscription = EmptyDisposable;
...@@ -117,14 +119,14 @@ export class HtmlPreviewPart extends BaseEditor { ...@@ -117,14 +119,14 @@ export class HtmlPreviewPart extends BaseEditor {
this.webview.style(this._themeService.getColorTheme()); this.webview.style(this._themeService.getColorTheme());
if (this._hasValidModel()) { if (this._hasValidModel()) {
this._modelChangeSubscription = this._model.onDidChangeContent(() => this.webview.contents = this._model.getLinesContent()); this._modelChangeSubscription = this.model.onDidChangeContent(() => this.webview.contents = this.model.getLinesContent());
this.webview.contents = this._model.getLinesContent(); this.webview.contents = this.model.getLinesContent();
} }
} }
} }
private _hasValidModel(): boolean { private _hasValidModel(): boolean {
return this._modelRef && this._model && !this._model.isDisposed(); return this._modelRef && this.model && !this.model.isDisposed();
} }
public layout(dimension: Dimension): void { public layout(dimension: Dimension): void {
...@@ -144,6 +146,10 @@ export class HtmlPreviewPart extends BaseEditor { ...@@ -144,6 +146,10 @@ export class HtmlPreviewPart extends BaseEditor {
super.clearInput(); super.clearInput();
} }
public sendMessage(data: any): void {
this.webview.sendMessage(data);
}
public setInput(input: EditorInput, options?: EditorOptions): TPromise<void> { public setInput(input: EditorInput, options?: EditorOptions): TPromise<void> {
if (this.input && this.input.matches(input) && this._hasValidModel()) { if (this.input && this.input.matches(input) && this._hasValidModel()) {
...@@ -168,13 +174,13 @@ export class HtmlPreviewPart extends BaseEditor { ...@@ -168,13 +174,13 @@ export class HtmlPreviewPart extends BaseEditor {
this._modelRef = ref; this._modelRef = ref;
} }
if (!this._model) { if (!this.model) {
return TPromise.wrapError<void>(localize('html.voidInput', "Invalid editor input.")); 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.baseUrl = resourceUri.toString(true);
this.webview.contents = this._model.getLinesContent(); this.webview.contents = this.model.getLinesContent();
}); });
}); });
} }
......
...@@ -128,6 +128,12 @@ document.addEventListener("DOMContentLoaded", function (event) { ...@@ -128,6 +128,12 @@ document.addEventListener("DOMContentLoaded", function (event) {
ipcRenderer.sendToHost('did-set-content', stats); 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 // forward messages from the embedded iframe
window.onmessage = function (message) { window.onmessage = function (message) {
ipcRenderer.sendToHost(message.data.command, message.data.data); ipcRenderer.sendToHost(message.data.command, message.data.data);
......
...@@ -158,6 +158,10 @@ export default class Webview { ...@@ -158,6 +158,10 @@ export default class Webview {
this._send('focus'); this._send('focus');
} }
public sendMessage(data: any): void {
this._send('message', data);
}
style(theme: IColorTheme): void { style(theme: IColorTheme): void {
let themeId = theme.id; let themeId = theme.id;
const {color, backgroundColor, fontFamily, fontWeight, fontSize} = window.getComputedStyle(this._styleElement); const {color, backgroundColor, fontFamily, fontWeight, fontSize} = window.getComputedStyle(this._styleElement);
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册