From b29ef9b4e82e1c782e80cb04e0c56659ce265496 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 18 Jan 2017 17:58:45 -0800 Subject: [PATCH] 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 --- extensions/markdown/media/main.js | 149 ++++++++++++++++-- extensions/markdown/media/markdown.css | 14 ++ extensions/markdown/package.json | 5 + extensions/markdown/package.nls.json | 3 +- extensions/markdown/src/extension.ts | 89 +++++++---- .../parts/html/browser/html.contribution.ts | 12 ++ .../parts/html/browser/htmlPreviewPart.ts | 20 ++- .../parts/html/browser/webview-pre.js | 6 + .../workbench/parts/html/browser/webview.ts | 4 + 9 files changed, 247 insertions(+), 55 deletions(-) diff --git a/extensions/markdown/media/main.js b/extensions/markdown/media/main.js index ed71ab3700e..74aae209103 100644 --- a/extensions/markdown/media/main.js +++ b/extensions/markdown/media/main.js @@ -5,16 +5,139 @@ 'use strict'; -let pageHeight = 0; - -window.onload = () => { - pageHeight = document.body.getBoundingClientRect().height; -}; - -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); +(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 }; + } + + 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 diff --git a/extensions/markdown/media/markdown.css b/extensions/markdown/media/markdown.css index 9ca2c0bbf86..3475fa82002 100644 --- a/extensions/markdown/media/markdown.css +++ b/extensions/markdown/media/markdown.css @@ -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%; diff --git a/extensions/markdown/package.json b/extensions/markdown/package.json index e7d7e7986e9..d55370c8205 100644 --- a/extensions/markdown/package.json +++ b/extensions/markdown/package.json @@ -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%" } } } diff --git a/extensions/markdown/package.nls.json b/extensions/markdown/package.nls.json index d14a486d977..be901187647 100644 --- a/extensions/markdown/package.nls.json +++ b/extensions/markdown/package.nls.json @@ -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" } \ No newline at end of file diff --git a/extensions/markdown/src/extension.ts b/extensions/markdown/src/extension.ts index a51cfcbf840..c859ecf5d50 100644 --- a/extensions/markdown/src/extension.ts +++ b/extensions/markdown/src/extension.ts @@ -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(); 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 [ - ''].join('\n'); + return ``; } public provideTextDocumentContent(uri: vscode.Uri): Thenable { @@ -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); + + 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).concat( - '', - '', - '', - '', - ``, - ``, - this.getSettingsOverrideStyles(), - this.computeCustomStyleSheetIncludes(uri), - ``, - '', - `` - ).join('\n'); - const body = this._renderer.render(this.getDocumentContentForPreview(document)); - - const tail = [ - ``, - '', - '' - ].join('\n'); - - return head + body + tail; + return ` + + + + + + ${this.getSettingsOverrideStyles()} + ${this.computeCustomStyleSheetIncludes(uri)} + + + + ${this._renderer.render(this.getDocumentContentForPreview(document))} + + + + `; }); } diff --git a/src/vs/workbench/parts/html/browser/html.contribution.ts b/src/vs/workbench/parts/html/browser/html.contribution.ts index 7c3fb2cdc9a..1e37fff0a68 100644 --- a/src/vs/workbench/parts/html/browser/html.contribution.ts +++ b/src/vs/workbench/parts/html/browser/html.contribution.ts @@ -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; +}); diff --git a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts index a87a915d26b..02be9e030c5 100644 --- a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts +++ b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts @@ -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; - 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 { 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(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(); }); }); } diff --git a/src/vs/workbench/parts/html/browser/webview-pre.js b/src/vs/workbench/parts/html/browser/webview-pre.js index d19b93a51ec..037f1c147dd 100644 --- a/src/vs/workbench/parts/html/browser/webview-pre.js +++ b/src/vs/workbench/parts/html/browser/webview-pre.js @@ -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); diff --git a/src/vs/workbench/parts/html/browser/webview.ts b/src/vs/workbench/parts/html/browser/webview.ts index 636132b36d4..6bfdb4cd779 100644 --- a/src/vs/workbench/parts/html/browser/webview.ts +++ b/src/vs/workbench/parts/html/browser/webview.ts @@ -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); -- GitLab