From 457890c709ecde33c16e0106e92192572e6795f3 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 8 Aug 2018 16:47:43 -0700 Subject: [PATCH] Always use the text editor to highlight code blocks When rendering HTML to be viewed outside of Atom, convert the text editor into standard elements using direct DOM manipulation. This will allow for greater consistency between the styling of highlighted code blocks and Atom's usual rendering. --- lib/main.coffee | 12 ++- lib/markdown-preview-view.coffee | 35 ++++--- lib/renderer.coffee | 139 ++++++++++++------------- package.json | 1 - spec/fixtures/saved-html.html | 2 +- spec/markdown-preview-spec.coffee | 18 +++- spec/markdown-preview-view-spec.coffee | 19 ++-- 7 files changed, 119 insertions(+), 107 deletions(-) diff --git a/lib/main.coffee b/lib/main.coffee index c700829..40bc785 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -118,11 +118,13 @@ module.exports = renderer ?= require './renderer' text = editor.getSelectedText() or editor.getText() - renderer.toHTML text, editor.getPath(), editor.getGrammar(), (error, html) -> - if error - console.warn('Copying Markdown as HTML failed', error) - else - atom.clipboard.write(html) + new Promise (resolve) -> + renderer.toHTML text, editor.getPath(), editor.getGrammar(), (error, html) -> + if error + console.warn('Copying Markdown as HTML failed', error) + else + atom.clipboard.write(html) + resolve() saveAsHTML: -> activePaneItem = atom.workspace.getActivePaneItem() diff --git a/lib/markdown-preview-view.coffee b/lib/markdown-preview-view.coffee index 6b3d7fb..9e6105d 100644 --- a/lib/markdown-preview-view.coffee +++ b/lib/markdown-preview-view.coffee @@ -330,20 +330,21 @@ class MarkdownPreviewView if filePath title = path.parse(filePath).name - @getHTML (error, htmlBody) => - if error? - throw error - else - html = """ - - - - - #{title} - - - #{htmlBody} - """ + "\n" # Ensure trailing newline - - fs.writeFileSync(htmlFilePath, html) - atom.workspace.open(htmlFilePath) + new Promise (resolve, reject) => + @getHTML (error, htmlBody) => + if error? + throw error + else + html = """ + + + + + #{title} + + + #{htmlBody} + """ + "\n" # Ensure trailing newline + + fs.writeFileSync(htmlFilePath, html) + atom.workspace.open(htmlFilePath).then(resolve) diff --git a/lib/renderer.coffee b/lib/renderer.coffee index 5ee28d0..382a021 100644 --- a/lib/renderer.coffee +++ b/lib/renderer.coffee @@ -1,8 +1,8 @@ +{TextEditor} = require 'atom' path = require 'path' cheerio = require 'cheerio' createDOMPurify = require 'dompurify' fs = require 'fs-plus' -Highlights = require 'highlights' roaster = null # Defer until used {scopeForFenceName} = require './extension-helper' @@ -11,25 +11,22 @@ highlighter = null packagePath = path.dirname(__dirname) exports.toDOMFragment = (text='', filePath, grammar, callback) -> - render text, filePath, (error, html) -> + render text, filePath, (error, domFragment) -> return callback(error) if error? - - template = document.createElement('template') - template.innerHTML = html - domFragment = template.content.cloneNode(true) - - # Default code blocks to be coffee in Literate CoffeeScript files - defaultCodeLanguage = 'coffee' if grammar?.scopeName is 'source.litcoffee' - convertCodeBlocksToAtomEditors(domFragment, defaultCodeLanguage) - callback(null, domFragment) + highlightCodeBlocks(domFragment, grammar, makeAtomEditorNonInteractive).then -> + callback(null, domFragment) exports.toHTML = (text='', filePath, grammar, callback) -> - render text, filePath, (error, html) -> + render text, filePath, (error, domFragment) -> return callback(error) if error? - # Default code blocks to be coffee in Literate CoffeeScript files - defaultCodeLanguage = 'coffee' if grammar?.scopeName is 'source.litcoffee' - html = tokenizeCodeBlocks(html, defaultCodeLanguage) - callback(null, html) + + div = document.createElement('div') + div.appendChild(domFragment) + document.body.appendChild(div) + + highlightCodeBlocks(div, grammar, convertAtomEditorToStandardElement).then -> + callback(null, div.innerHTML) + div.remove() render = (text, filePath, callback) -> roaster ?= require 'roaster' @@ -45,14 +42,17 @@ render = (text, filePath, callback) -> return callback(error) if error? html = createDOMPurify().sanitize(html, {ALLOW_UNKNOWN_PROTOCOLS: atom.config.get('markdown-preview.allowUnsafeProtocols')}) - html = resolveImagePaths(html, filePath) - callback(null, html.trim()) -resolveImagePaths = (html, filePath) -> + template = document.createElement('template') + template.innerHTML = html.trim() + fragment = template.content.cloneNode(true) + + resolveImagePaths(fragment, filePath) + callback(null, fragment) + +resolveImagePaths = (element, filePath) -> [rootDirectory] = atom.project.relativizePath(filePath) - o = document.createElement('div') - o.innerHTML = html - for img in o.querySelectorAll('img') + for img in element.querySelectorAll('img') # We use the raw attribute instead of the .src property because the value # of the property seems to be transformed in some cases. if src = img.getAttribute('src') @@ -68,60 +68,55 @@ resolveImagePaths = (html, filePath) -> else img.src = path.resolve(path.dirname(filePath), src) - o.innerHTML +highlightCodeBlocks = (domFragment, grammar, editorCallback) -> + if grammar?.scopeName is 'source.litcoffee' + defaultLanguage = 'coffee' + else + defaultLanguage = 'text' -convertCodeBlocksToAtomEditors = (domFragment, defaultLanguage='text') -> if fontFamily = atom.config.get('editor.fontFamily') for codeElement in domFragment.querySelectorAll('code') codeElement.style.fontFamily = fontFamily + promises = [] for preElement in domFragment.querySelectorAll('pre') - codeBlock = preElement.firstElementChild ? preElement - fenceName = codeBlock.getAttribute('class')?.replace(/^lang-/, '') ? defaultLanguage - - editorElement = document.createElement('atom-text-editor') - - preElement.parentNode.insertBefore(editorElement, preElement) - preElement.remove() - + do (preElement) -> + codeBlock = preElement.firstElementChild ? preElement + fenceName = codeBlock.getAttribute('class')?.replace(/^lang-/, '') ? defaultLanguage + preElement.classList.add('editor-colors', "lang-#{fenceName}") + editor = new TextEditor({readonly: true, keyboardInputEnabled: false}) + editorElement = editor.getElement() + editorElement.setUpdatedSynchronously(true) + preElement.innerHTML = '' + preElement.parentNode.insertBefore(editorElement, preElement) + editor.setText(codeBlock.textContent.replace(/\r?\n$/, '')) + atom.grammars.assignLanguageMode(editor, scopeForFenceName(fenceName)) + editor.setVisible(true) + promises.push(editorCallback(editorElement, preElement)) + Promise.all(promises) + +makeAtomEditorNonInteractive = (editorElement, preElement) -> + preElement.remove() + editorElement.setAttributeNode(document.createAttribute('gutter-hidden')) # Hide gutter + editorElement.removeAttribute('tabindex') # Make read-only + + # Remove line decorations from code blocks. + for cursorLineDecoration in editorElement.getModel().cursorLineDecorations + cursorLineDecoration.destroy() + return + +convertAtomEditorToStandardElement = (editorElement, preElement) -> + new Promise (resolve) -> + done = -> + for line in editorElement.querySelectorAll('.line:not(.dummy)') + line2 = document.createElement('div') + line2.className = 'line' + line2.innerHTML = line.firstChild.innerHTML + preElement.appendChild(line2) + editorElement.remove() + resolve() editor = editorElement.getModel() - lastNewlineIndex = codeBlock.textContent.search(/\r?\n$/) - editor.setText(codeBlock.textContent.substring(0, lastNewlineIndex)) # Do not include a trailing newline - editorElement.setAttributeNode(document.createAttribute('gutter-hidden')) # Hide gutter - editorElement.removeAttribute('tabindex') # Make read-only - - if grammar = atom.grammars.grammarForScopeName(scopeForFenceName(fenceName)) - editor.setGrammar(grammar) - - # Remove line decorations from code blocks. - for cursorLineDecoration in editor.cursorLineDecorations - cursorLineDecoration.destroy() - - domFragment - -tokenizeCodeBlocks = (html, defaultLanguage='text') -> - o = document.createElement('div') - o.innerHTML = html - - if fontFamily = atom.config.get('editor.fontFamily') - for codeElement in o.querySelectorAll('code') - codeElement.style['font-family'] = fontFamily - - for preElement in o.querySelectorAll("pre") - codeBlock = preElement.children[0] - fenceName = codeBlock.className?.replace(/^lang-/, '') ? defaultLanguage - - highlighter ?= new Highlights(registry: atom.grammars, scopePrefix: 'syntax--') - highlightedHtml = highlighter.highlightSync - fileContents: codeBlock.textContent - scopeName: scopeForFenceName(fenceName) - - highlightedBlock = document.createElement('pre') - highlightedBlock.innerHTML = highlightedHtml - # The `editor` class messes things up as `.editor` has absolutely positioned lines - highlightedBlock.children[0].classList.remove('editor') - highlightedBlock.children[0].classList.add("lang-#{fenceName}") - - preElement.outerHTML = highlightedBlock.innerHTML - - o.innerHTML + if editor.getBuffer().getLanguageMode().fullyTokenized + done() + else + editor.onDidTokenize(done) diff --git a/package.json b/package.json index 767b7c5..5612bd5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "dependencies": { "dompurify": "^1.0.2", "fs-plus": "^3.0.0", - "highlights": "^3.1.1", "roaster": "^1.2.1", "underscore-plus": "^1.0.0" }, diff --git a/spec/fixtures/saved-html.html b/spec/fixtures/saved-html.html index 682365b..eb592b4 100644 --- a/spec/fixtures/saved-html.html +++ b/spec/fixtures/saved-html.html @@ -11,6 +11,6 @@ pre.editor-colors .hr { background: url('data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAECAYAAACtBE5DAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OENDRjNBN0E2NTZBMTFFMEI3QjRBODM4NzJDMjlGNDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OENDRjNBN0I2NTZBMTFFMEI3QjRBODM4NzJDMjlGNDgiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo4Q0NGM0E3ODY1NkExMUUwQjdCNEE4Mzg3MkMyOUY0OCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo4Q0NGM0E3OTY1NkExMUUwQjdCNEE4Mzg3MkMyOUY0OCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PqqezsUAAAAfSURBVHjaYmRABcYwBiM2QSA4y4hNEKYDQxAEAAIMAHNGAzhkPOlYAAAAAElFTkSuQmCC'); }

Code Block

-
if a === 3 {
  b = 5
}
+
if a === 3 {
b = 5
}

encoding → issue

diff --git a/spec/markdown-preview-spec.coffee b/spec/markdown-preview-spec.coffee index ef4765b..8877902 100644 --- a/spec/markdown-preview-spec.coffee +++ b/spec/markdown-preview-spec.coffee @@ -2,6 +2,8 @@ path = require 'path' fs = require 'fs-plus' temp = require('temp').track() MarkdownPreviewView = require '../lib/markdown-preview-view' +{TextEditor} = require 'atom' +TextMateLanguageMode = new TextEditor().getBuffer().getLanguageMode().constructor describe "Markdown Preview", -> preview = null @@ -12,6 +14,8 @@ describe "Markdown Preview", -> fs.copySync(fixturesPath, tempPath) atom.project.setPaths([tempPath]) + jasmine.unspy(TextMateLanguageMode.prototype, 'tokenizeInBackground') + jasmine.useRealClock() jasmine.attachToDOM(atom.views.getView(atom.workspace)) @@ -348,8 +352,10 @@ describe "Markdown Preview", -> waitsForPromise -> atom.workspace.open("subdir/simple.md") - runs -> + waitsForPromise -> atom.commands.dispatch atom.workspace.getActiveTextEditor().getElement(), 'markdown-preview:copy-html' + + runs -> expect(atom.clipboard.read()).toBe """

italic

bold

@@ -357,14 +363,16 @@ describe "Markdown Preview", -> """ atom.workspace.getActiveTextEditor().setSelectedBufferRange [[0, 0], [1, 0]] + + waitsForPromise -> atom.commands.dispatch atom.workspace.getActiveTextEditor().getElement(), 'markdown-preview:copy-html' + + runs -> expect(atom.clipboard.read()).toBe """

italic

""" describe "code block tokenization", -> - preview = null - beforeEach -> waitsForPromise -> atom.packages.activatePackage('language-ruby') @@ -375,8 +383,10 @@ describe "Markdown Preview", -> waitsForPromise -> atom.workspace.open("subdir/file.markdown") - runs -> + waitsForPromise -> atom.commands.dispatch atom.workspace.getActiveTextEditor().getElement(), 'markdown-preview:copy-html' + + runs -> preview = document.createElement('div') preview.innerHTML = atom.clipboard.read() diff --git a/spec/markdown-preview-view-spec.coffee b/spec/markdown-preview-view-spec.coffee index 1bb470d..f2da015 100644 --- a/spec/markdown-preview-view-spec.coffee +++ b/spec/markdown-preview-view-spec.coffee @@ -2,7 +2,9 @@ path = require 'path' fs = require 'fs-plus' temp = require('temp').track() url = require 'url' +{TextEditor} = require 'atom' MarkdownPreviewView = require '../lib/markdown-preview-view' +TextMateLanguageMode = new TextEditor().getBuffer().getLanguageMode().constructor describe "MarkdownPreviewView", -> preview = null @@ -11,6 +13,8 @@ describe "MarkdownPreviewView", -> # Makes _.debounce work jasmine.useRealClock() + jasmine.unspy(TextMateLanguageMode.prototype, 'tokenizeInBackground') + spyOn(atom.packages, 'hasActivatedInitialPackages').andReturn true filePath = atom.project.getDirectories()[0].resolve('subdir/file.markdown') @@ -306,12 +310,11 @@ describe "MarkdownPreviewView", -> "atom-text-editor .hr { background: url(atom://markdown-preview/assets/hr.png); }" ] - expect(fs.isFileSync(outputPath)).toBe false - waitsForPromise -> preview.renderMarkdown() runs -> + expect(fs.isFileSync(outputPath)).toBe false spyOn(preview, 'getSaveDialogOptions').andReturn({defaultPath: outputPath}) spyOn(atom.applicationDelegate, 'showSaveDialog').andCallFake (options, callback) -> callback?(options.defaultPath) @@ -320,6 +323,8 @@ describe "MarkdownPreviewView", -> return options.defaultPath spyOn(preview, 'getDocumentStyleSheets').andReturn(markdownPreviewStyles) spyOn(preview, 'getTextEditorStyles').andReturn(atomTextEditorStyles) + + waitsForPromise -> atom.commands.dispatch preview.element, 'core:save-as' waitsFor -> @@ -365,16 +370,16 @@ describe "MarkdownPreviewView", -> describe "when there is no text selected", -> it "copies the rendered HTML of the entire Markdown document to the clipboard", -> - atom.commands.dispatch preview.element, 'core:copy' + expect(atom.clipboard.read()).toBe("initial clipboard content") - waitsFor -> - atom.clipboard.read() isnt "initial clipboard content" + waitsForPromise -> + atom.commands.dispatch preview.element, 'core:copy' runs -> expect(atom.clipboard.read()).toBe """

Code Block

-
if a === 3 {
  b = 5
}
-

encoding \u2192 issue

+
if a === 3 {
b = 5
}
+

encoding → issue

""" describe "when there is a text selection", ->