mirror of
https://github.com/Microsoft/vscode
synced 2024-10-30 11:10:48 +00:00
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:
parent
5dc2fb8c30
commit
b29ef9b4e8
9 changed files with 244 additions and 52 deletions
|
@ -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);
|
||||
};
|
||||
*/
|
||||
}
|
||||
}());
|
|
@ -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%;
|
||||
|
|
|
@ -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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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>`;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue