mirror of
https://github.com/pulsar-edit/pulsar.git
synced 2024-08-16 14:40:23 +03:00
Merge pull request #984 from savetheclocktower/markdown-preview-performance
[markdown-preview] Optimize re-rendering of content in a preview pane…
This commit is contained in:
commit
217313b911
@ -12,10 +12,18 @@ const isMarkdownPreviewView = function (object) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
activate () {
|
activate() {
|
||||||
this.disposables = new CompositeDisposable()
|
this.disposables = new CompositeDisposable()
|
||||||
this.commandSubscriptions = new CompositeDisposable()
|
this.commandSubscriptions = new CompositeDisposable()
|
||||||
|
|
||||||
|
this.style = new CSSStyleSheet()
|
||||||
|
|
||||||
|
// TODO: When we upgrade Electron, we can push onto `adoptedStyleSheets`
|
||||||
|
// directly. For now, we have to do this silly thing.
|
||||||
|
let styleSheets = Array.from(document.adoptedStyleSheets ?? [])
|
||||||
|
styleSheets.push(this.style)
|
||||||
|
document.adoptedStyleSheets = styleSheets
|
||||||
|
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
atom.config.observe('markdown-preview.grammars', grammars => {
|
atom.config.observe('markdown-preview.grammars', grammars => {
|
||||||
this.commandSubscriptions.dispose()
|
this.commandSubscriptions.dispose()
|
||||||
@ -53,6 +61,22 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this.disposables.add(
|
||||||
|
atom.config.observe('editor.fontFamily', (fontFamily) => {
|
||||||
|
// Keep the user's `fontFamily` setting in sync with preview styles.
|
||||||
|
// `pre` blocks will use this font automatically, but `code` elements
|
||||||
|
// need a specific style rule.
|
||||||
|
//
|
||||||
|
// Since this applies to all content, we should declare this only once,
|
||||||
|
// instead of once per preview view.
|
||||||
|
this.style.replaceSync(`
|
||||||
|
.markdown-preview code {
|
||||||
|
font-family: ${fontFamily} !important;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const previewFile = this.previewFile.bind(this)
|
const previewFile = this.previewFile.bind(this)
|
||||||
for (const extension of [
|
for (const extension of [
|
||||||
'markdown',
|
'markdown',
|
||||||
@ -94,12 +118,12 @@ module.exports = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
deactivate () {
|
deactivate() {
|
||||||
this.disposables.dispose()
|
this.disposables.dispose()
|
||||||
this.commandSubscriptions.dispose()
|
this.commandSubscriptions.dispose()
|
||||||
},
|
},
|
||||||
|
|
||||||
createMarkdownPreviewView (state) {
|
createMarkdownPreviewView(state) {
|
||||||
if (state.editorId || fs.isFileSync(state.filePath)) {
|
if (state.editorId || fs.isFileSync(state.filePath)) {
|
||||||
if (MarkdownPreviewView == null) {
|
if (MarkdownPreviewView == null) {
|
||||||
MarkdownPreviewView = require('./markdown-preview-view')
|
MarkdownPreviewView = require('./markdown-preview-view')
|
||||||
@ -108,7 +132,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
toggle () {
|
toggle() {
|
||||||
if (isMarkdownPreviewView(atom.workspace.getActivePaneItem())) {
|
if (isMarkdownPreviewView(atom.workspace.getActivePaneItem())) {
|
||||||
atom.workspace.destroyActivePaneItem()
|
atom.workspace.destroyActivePaneItem()
|
||||||
return
|
return
|
||||||
@ -129,11 +153,11 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
uriForEditor (editor) {
|
uriForEditor(editor) {
|
||||||
return `markdown-preview://editor/${editor.id}`
|
return `markdown-preview://editor/${editor.id}`
|
||||||
},
|
},
|
||||||
|
|
||||||
removePreviewForEditor (editor) {
|
removePreviewForEditor(editor) {
|
||||||
const uri = this.uriForEditor(editor)
|
const uri = this.uriForEditor(editor)
|
||||||
const previewPane = atom.workspace.paneForURI(uri)
|
const previewPane = atom.workspace.paneForURI(uri)
|
||||||
if (previewPane != null) {
|
if (previewPane != null) {
|
||||||
@ -144,7 +168,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addPreviewForEditor (editor) {
|
addPreviewForEditor(editor) {
|
||||||
const uri = this.uriForEditor(editor)
|
const uri = this.uriForEditor(editor)
|
||||||
const previousActivePane = atom.workspace.getActivePane()
|
const previousActivePane = atom.workspace.getActivePane()
|
||||||
const options = { searchAllPanes: true }
|
const options = { searchAllPanes: true }
|
||||||
@ -161,7 +185,7 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
previewFile ({ target }) {
|
previewFile({ target }) {
|
||||||
const filePath = target.dataset.path
|
const filePath = target.dataset.path
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return
|
return
|
||||||
@ -178,7 +202,7 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async copyHTML () {
|
async copyHTML() {
|
||||||
const editor = atom.workspace.getActiveTextEditor()
|
const editor = atom.workspace.getActiveTextEditor()
|
||||||
if (editor == null) {
|
if (editor == null) {
|
||||||
return
|
return
|
||||||
@ -191,13 +215,14 @@ module.exports = {
|
|||||||
const html = await renderer.toHTML(
|
const html = await renderer.toHTML(
|
||||||
text,
|
text,
|
||||||
editor.getPath(),
|
editor.getPath(),
|
||||||
editor.getGrammar()
|
editor.getGrammar(),
|
||||||
|
editor.id
|
||||||
)
|
)
|
||||||
|
|
||||||
atom.clipboard.write(html)
|
atom.clipboard.write(html)
|
||||||
},
|
},
|
||||||
|
|
||||||
saveAsHTML () {
|
saveAsHTML() {
|
||||||
const activePaneItem = atom.workspace.getActivePaneItem()
|
const activePaneItem = atom.workspace.getActivePaneItem()
|
||||||
if (isMarkdownPreviewView(activePaneItem)) {
|
if (isMarkdownPreviewView(activePaneItem)) {
|
||||||
atom.workspace.getActivePane().saveItemAs(activePaneItem)
|
atom.workspace.getActivePane().saveItemAs(activePaneItem)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const morphdom = require('morphdom')
|
||||||
|
|
||||||
const { Emitter, Disposable, CompositeDisposable, File } = require('atom')
|
const { Emitter, Disposable, CompositeDisposable, File } = require('atom')
|
||||||
const _ = require('underscore-plus')
|
const _ = require('underscore-plus')
|
||||||
@ -17,6 +18,7 @@ module.exports = class MarkdownPreviewView {
|
|||||||
this.element = document.createElement('div')
|
this.element = document.createElement('div')
|
||||||
this.element.classList.add('markdown-preview')
|
this.element.classList.add('markdown-preview')
|
||||||
this.element.tabIndex = -1
|
this.element.tabIndex = -1
|
||||||
|
|
||||||
this.emitter = new Emitter()
|
this.emitter = new Emitter()
|
||||||
this.loaded = false
|
this.loaded = false
|
||||||
this.disposables = new CompositeDisposable()
|
this.disposables = new CompositeDisposable()
|
||||||
@ -32,6 +34,7 @@ module.exports = class MarkdownPreviewView {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
this.editorCache = new renderer.EditorCache(editorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize() {
|
serialize() {
|
||||||
@ -52,6 +55,7 @@ module.exports = class MarkdownPreviewView {
|
|||||||
destroy() {
|
destroy() {
|
||||||
this.disposables.dispose()
|
this.disposables.dispose()
|
||||||
this.element.remove()
|
this.element.remove()
|
||||||
|
this.editorCache.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
registerScrollCommands() {
|
registerScrollCommands() {
|
||||||
@ -83,7 +87,7 @@ module.exports = class MarkdownPreviewView {
|
|||||||
return this.emitter.on('did-change-title', callback)
|
return this.emitter.on('did-change-title', callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
onDidChangeModified(callback) {
|
onDidChangeModified(_callback) {
|
||||||
// No op to suppress deprecation warning
|
// No op to suppress deprecation warning
|
||||||
return new Disposable()
|
return new Disposable()
|
||||||
}
|
}
|
||||||
@ -270,7 +274,22 @@ module.exports = class MarkdownPreviewView {
|
|||||||
return this.getMarkdownSource()
|
return this.getMarkdownSource()
|
||||||
.then(source => {
|
.then(source => {
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
return this.renderMarkdownText(source)
|
if (this.loaded) {
|
||||||
|
return this.renderMarkdownText(source);
|
||||||
|
} else {
|
||||||
|
// If we haven't loaded yet, defer before we render the Markdown
|
||||||
|
// for the first time. This allows the pane to appear and to
|
||||||
|
// display the loading indicator. Otherwise the first render
|
||||||
|
// happens before the pane is even visible.
|
||||||
|
//
|
||||||
|
// This doesn't slow anything down; it just shifts the work around
|
||||||
|
// so that the pane appears earlier in the cycle.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(this.renderMarkdownText(source))
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(reason => this.showError({ message: reason }))
|
.catch(reason => this.showError({ message: reason }))
|
||||||
@ -309,18 +328,34 @@ module.exports = class MarkdownPreviewView {
|
|||||||
|
|
||||||
async renderMarkdownText(text) {
|
async renderMarkdownText(text) {
|
||||||
const { scrollTop } = this.element
|
const { scrollTop } = this.element
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const domFragment = await renderer.toDOMFragment(
|
const [domFragment, done] = await renderer.toDOMFragment(
|
||||||
text,
|
text,
|
||||||
this.getPath(),
|
this.getPath(),
|
||||||
this.getGrammar()
|
this.getGrammar(),
|
||||||
|
this.editorId
|
||||||
)
|
)
|
||||||
|
|
||||||
this.loading = false
|
this.loading = false
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
this.element.textContent = ''
|
|
||||||
this.element.appendChild(domFragment)
|
// Clone the existing container
|
||||||
|
let newElement = this.element.cloneNode(false)
|
||||||
|
newElement.appendChild(domFragment)
|
||||||
|
|
||||||
|
morphdom(this.element, newElement, {
|
||||||
|
onBeforeNodeDiscarded(node) {
|
||||||
|
// Don't discard `atom-text-editor` elements despite the fact that
|
||||||
|
// they don't exist in the new content.
|
||||||
|
if (node.nodeName === 'ATOM-TEXT-EDITOR') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await done(this.element)
|
||||||
|
this.element.classList.remove('loading')
|
||||||
|
|
||||||
this.emitter.emit('did-change-markdown')
|
this.emitter.emit('did-change-markdown')
|
||||||
this.element.scrollTop = scrollTop
|
this.element.scrollTop = scrollTop
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -400,7 +435,7 @@ module.exports = class MarkdownPreviewView {
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
.replace(/atom-text-editor/g, 'pre.editor-colors')
|
.replace(/atom-text-editor/g, 'pre.editor-colors')
|
||||||
.replace(/:host/g, '.host') // Remove shadow-dom :host selector causing problem on FF
|
.replace(/:host/g, '.host') // Remove shadow-dom :host selector causing problem on FF
|
||||||
.replace(cssUrlRegExp, function (match, assetsName, offset, string) {
|
.replace(cssUrlRegExp, function (_match, assetsName, _offset, _string) {
|
||||||
// base64 encode assets
|
// base64 encode assets
|
||||||
const assetPath = path.join(__dirname, '../assets', assetsName)
|
const assetPath = path.join(__dirname, '../assets', assetsName)
|
||||||
const originalData = fs.readFileSync(assetPath, 'binary')
|
const originalData = fs.readFileSync(assetPath, 'binary')
|
||||||
@ -413,6 +448,7 @@ module.exports = class MarkdownPreviewView {
|
|||||||
|
|
||||||
showError(result) {
|
showError(result) {
|
||||||
this.element.textContent = ''
|
this.element.textContent = ''
|
||||||
|
this.element.classList.remove('loading')
|
||||||
const h2 = document.createElement('h2')
|
const h2 = document.createElement('h2')
|
||||||
h2.textContent = 'Previewing Markdown Failed'
|
h2.textContent = 'Previewing Markdown Failed'
|
||||||
this.element.appendChild(h2)
|
this.element.appendChild(h2)
|
||||||
@ -425,11 +461,7 @@ module.exports = class MarkdownPreviewView {
|
|||||||
|
|
||||||
showLoading() {
|
showLoading() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.element.textContent = ''
|
this.element.classList.add('loading')
|
||||||
const div = document.createElement('div')
|
|
||||||
div.classList.add('markdown-spinner')
|
|
||||||
div.textContent = 'Loading Markdown\u2026'
|
|
||||||
this.element.appendChild(div)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectAll() {
|
selectAll() {
|
||||||
|
@ -17,91 +17,147 @@ const emojiFolder = path.join(
|
|||||||
'pngs'
|
'pngs'
|
||||||
)
|
)
|
||||||
|
|
||||||
exports.toDOMFragment = async function (text, filePath, grammar, callback) {
|
// Creating `TextEditor` instances is costly, so we'll try to re-use instances
|
||||||
|
// when a preview changes.
|
||||||
|
class EditorCache {
|
||||||
|
static BY_ID = new Map()
|
||||||
|
|
||||||
text ??= "";
|
static findOrCreateById(id) {
|
||||||
|
let cache = EditorCache.BY_ID.get(id)
|
||||||
if (atom.config.get("markdown-preview.useOriginalParser")) {
|
if (!cache) {
|
||||||
const domFragment = render(text, filePath);
|
cache = new EditorCache(id)
|
||||||
|
EditorCache.BY_ID.set(id, cache)
|
||||||
await highlightCodeBlocks(domFragment, grammar, makeAtomEditorNonInteractive);
|
}
|
||||||
|
return cache
|
||||||
return domFragment;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// We use the new parser!
|
|
||||||
const domFragment = atom.ui.markdown.render(text,
|
|
||||||
{
|
|
||||||
renderMode: "fragment",
|
|
||||||
filePath: filePath,
|
|
||||||
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
|
|
||||||
useDefaultEmoji: true,
|
|
||||||
sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols')
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const domHTMLFragment = atom.ui.markdown.convertToDOM(domFragment);
|
|
||||||
await atom.ui.markdown.applySyntaxHighlighting(domHTMLFragment,
|
|
||||||
{
|
|
||||||
renderMode: "fragment",
|
|
||||||
syntaxScopeNameFunc: scopeForFenceName,
|
|
||||||
grammar: grammar
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return domHTMLFragment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(id) {
|
||||||
|
this.id = id
|
||||||
|
this.editorsByPre = new Map()
|
||||||
|
this.possiblyUnusedEditors = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
let editors = Array.from(this.editorsByPre.values())
|
||||||
|
for (let editor of editors) {
|
||||||
|
editor.destroy()
|
||||||
|
}
|
||||||
|
this.editorsByPre.clear()
|
||||||
|
this.possiblyUnusedEditors.clear()
|
||||||
|
EditorCache.BY_ID.delete(this.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when we start a render. Every `TextEditor` is assumed to be stale,
|
||||||
|
// but any editor that is successfully looked up from the cache during this
|
||||||
|
// render is saved from culling.
|
||||||
|
beginRender() {
|
||||||
|
this.possiblyUnusedEditors.clear()
|
||||||
|
for (let editor of this.editorsByPre.values()) {
|
||||||
|
this.possiblyUnusedEditors.add(editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache an editor by the PRE element that it's standing in for.
|
||||||
|
addEditor(pre, editor) {
|
||||||
|
this.editorsByPre.set(pre, editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
getEditor(pre) {
|
||||||
|
let editor = this.editorsByPre.get(pre)
|
||||||
|
if (editor) {
|
||||||
|
// Cache hit! This editor will be reused, so we should prevent it from
|
||||||
|
// getting culled.
|
||||||
|
this.possiblyUnusedEditors.delete(editor)
|
||||||
|
}
|
||||||
|
return editor
|
||||||
|
}
|
||||||
|
|
||||||
|
endRender() {
|
||||||
|
// Any editor that didn't get claimed during the render is orphaned and
|
||||||
|
// should be disposed of.
|
||||||
|
let toBeDeleted = new Set()
|
||||||
|
for (let [pre, editor] of this.editorsByPre.entries()) {
|
||||||
|
if (!this.possiblyUnusedEditors.has(editor)) continue
|
||||||
|
toBeDeleted.add(pre)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.possiblyUnusedEditors.clear()
|
||||||
|
|
||||||
|
for (let pre of toBeDeleted) {
|
||||||
|
let editor = this.editorsByPre.get(pre)
|
||||||
|
let element = editor.getElement()
|
||||||
|
if (element.parentNode) {
|
||||||
|
element.remove()
|
||||||
|
}
|
||||||
|
this.editorsByPre.delete(pre)
|
||||||
|
editor.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.EditorCache = EditorCache
|
||||||
|
|
||||||
|
function chooseRender(text, filePath) {
|
||||||
|
if (atom.config.get("markdown-preview.useOriginalParser")) {
|
||||||
|
// Legacy rendering with `marked`.
|
||||||
|
return render(text, filePath)
|
||||||
|
} else {
|
||||||
|
// Built-in rendering with `markdown-it`.
|
||||||
|
let html = atom.ui.markdown.render(text, {
|
||||||
|
renderMode: "fragment",
|
||||||
|
filePath: filePath,
|
||||||
|
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
|
||||||
|
useDefaultEmoji: true,
|
||||||
|
sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols')
|
||||||
|
})
|
||||||
|
return atom.ui.markdown.convertToDOM(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.toDOMFragment = async function (text, filePath, grammar, editorId) {
|
||||||
|
text ??= ""
|
||||||
|
let defaultLanguage = getDefaultLanguageForGrammar(grammar)
|
||||||
|
|
||||||
|
// We cache editor instances in this code path because it's the one used by
|
||||||
|
// the preview pane, so we expect it to be updated quite frequently.
|
||||||
|
let cache = EditorCache.findOrCreateById(editorId)
|
||||||
|
cache.beginRender()
|
||||||
|
|
||||||
|
const domFragment = chooseRender(text, filePath)
|
||||||
|
annotatePreElements(domFragment, defaultLanguage)
|
||||||
|
|
||||||
|
return [
|
||||||
|
domFragment,
|
||||||
|
async (element) => {
|
||||||
|
await highlightCodeBlocks(element, grammar, cache, makeAtomEditorNonInteractive)
|
||||||
|
cache.endRender()
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.toHTML = async function (text, filePath, grammar) {
|
exports.toHTML = async function (text, filePath, grammar) {
|
||||||
|
|
||||||
text ??= "";
|
text ??= "";
|
||||||
|
|
||||||
if (atom.config.get("markdown-preview.useOriginalParser")) {
|
// We don't cache editor instances in this code path because it's the one
|
||||||
const domFragment = render(text, filePath)
|
// used by the “Copy HTML” command, so this is likely to be a one-off for
|
||||||
const div = document.createElement('div')
|
// which caches won't help.
|
||||||
|
|
||||||
div.appendChild(domFragment)
|
const domFragment = chooseRender(text, filePath)
|
||||||
document.body.appendChild(div)
|
const div = document.createElement('div')
|
||||||
|
annotatePreElements(domFragment, getDefaultLanguageForGrammar(grammar))
|
||||||
|
div.appendChild(domFragment)
|
||||||
|
document.body.appendChild(div)
|
||||||
|
|
||||||
await highlightCodeBlocks(div, grammar, convertAtomEditorToStandardElement)
|
await highlightCodeBlocks(div, grammar, null, convertAtomEditorToStandardElement)
|
||||||
|
|
||||||
const result = div.innerHTML
|
const result = div.innerHTML;
|
||||||
div.remove()
|
div.remove();
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
} else {
|
|
||||||
// We use the new parser!
|
|
||||||
const domFragment = atom.ui.markdown.render(text,
|
|
||||||
{
|
|
||||||
renderMode: "full",
|
|
||||||
filePath: filePath,
|
|
||||||
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
|
|
||||||
useDefaultEmoji: true,
|
|
||||||
sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols')
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const domHTMLFragment = atom.ui.markdown.convertToDOM(domFragment);
|
|
||||||
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.appendChild(domHTMLFragment);
|
|
||||||
document.body.appendChild(div);
|
|
||||||
|
|
||||||
await atom.ui.markdown.applySyntaxHighlighting(div,
|
|
||||||
{
|
|
||||||
renderMode: "full",
|
|
||||||
syntaxScopeNameFunc: scopeForFenceName,
|
|
||||||
grammar: grammar
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = div.innerHTML;
|
|
||||||
div.remove();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var render = function (text, filePath) {
|
// Render with the package's own `marked` library.
|
||||||
|
function render(text, filePath) {
|
||||||
if (marked == null || yamlFrontMatter == null || cheerio == null) {
|
if (marked == null || yamlFrontMatter == null || cheerio == null) {
|
||||||
marked = require('marked')
|
marked = require('marked')
|
||||||
yamlFrontMatter = require('yaml-front-matter')
|
yamlFrontMatter = require('yaml-front-matter')
|
||||||
@ -124,12 +180,13 @@ var render = function (text, filePath) {
|
|||||||
|
|
||||||
let html = marked.parse(renderYamlTable(vars) + __content)
|
let html = marked.parse(renderYamlTable(vars) + __content)
|
||||||
|
|
||||||
// emoji-images is too aggressive, so replace images in monospace tags with the actual emoji text.
|
// emoji-images is too aggressive, so replace images in monospace tags with
|
||||||
|
// the actual emoji text.
|
||||||
const $ = cheerio.load(emoji(html, emojiFolder, 20))
|
const $ = cheerio.load(emoji(html, emojiFolder, 20))
|
||||||
$('pre img').each((index, element) =>
|
$('pre img').each((_index, element) =>
|
||||||
$(element).replaceWith($(element).attr('title'))
|
$(element).replaceWith($(element).attr('title'))
|
||||||
)
|
)
|
||||||
$('code img').each((index, element) =>
|
$('code img').each((_index, element) =>
|
||||||
$(element).replaceWith($(element).attr('title'))
|
$(element).replaceWith($(element).attr('title'))
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -159,7 +216,7 @@ function renderYamlTable(variables) {
|
|||||||
|
|
||||||
const markdownRows = [
|
const markdownRows = [
|
||||||
entries.map(entry => entry[0]),
|
entries.map(entry => entry[0]),
|
||||||
entries.map(entry => '--'),
|
entries.map(_ => '--'),
|
||||||
entries.map((entry) => {
|
entries.map((entry) => {
|
||||||
if (typeof entry[1] === "object" && !Array.isArray(entry[1])) {
|
if (typeof entry[1] === "object" && !Array.isArray(entry[1])) {
|
||||||
// Remove all newlines, or they ruin formatting of parent table
|
// Remove all newlines, or they ruin formatting of parent table
|
||||||
@ -175,7 +232,7 @@ function renderYamlTable(variables) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolveImagePaths = function (element, filePath) {
|
function resolveImagePaths(element, filePath) {
|
||||||
const [rootDirectory] = atom.project.relativizePath(filePath)
|
const [rootDirectory] = atom.project.relativizePath(filePath)
|
||||||
|
|
||||||
const result = []
|
const result = []
|
||||||
@ -219,55 +276,89 @@ var resolveImagePaths = function (element, filePath) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var highlightCodeBlocks = function (domFragment, grammar, editorCallback) {
|
function getDefaultLanguageForGrammar(grammar) {
|
||||||
let defaultLanguage, fontFamily
|
return grammar?.scopeName === 'source.litcoffee' ? 'coffee' : 'text'
|
||||||
if (
|
}
|
||||||
(grammar != null ? grammar.scopeName : undefined) === 'source.litcoffee'
|
|
||||||
) {
|
|
||||||
defaultLanguage = 'coffee'
|
|
||||||
} else {
|
|
||||||
defaultLanguage = 'text'
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((fontFamily = atom.config.get('editor.fontFamily'))) {
|
function annotatePreElements(fragment, defaultLanguage) {
|
||||||
for (const codeElement of domFragment.querySelectorAll('code')) {
|
for (let preElement of fragment.querySelectorAll('pre')) {
|
||||||
codeElement.style.fontFamily = fontFamily
|
const codeBlock = preElement.firstElementChild ?? preElement
|
||||||
}
|
const className = codeBlock.getAttribute('class')
|
||||||
|
const fenceName = className?.replace(/^language-/, '') ?? defaultLanguage
|
||||||
|
preElement.classList.add('editor-colors', `lang-${fenceName}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reassignEditorToLanguage(editor, languageScope) {
|
||||||
|
// When we successfully reassign the language on an editor, its
|
||||||
|
// `data-grammar` attribute updates on its own.
|
||||||
|
let result = atom.grammars.assignLanguageMode(editor, languageScope)
|
||||||
|
if (result) return true
|
||||||
|
|
||||||
|
// When we fail to assign the language on an editor — maybe its package is
|
||||||
|
// deactivated — it won't reset itself to the default grammar, so we have to
|
||||||
|
// do it ourselves.
|
||||||
|
result = atom.grammars.assignLanguageMode(editor, `text.plain.null-grammar`)
|
||||||
|
if (!result) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// After render, create an `atom-text-editor` for each `pre` element so that we
|
||||||
|
// enjoy syntax highlighting.
|
||||||
|
function highlightCodeBlocks(element, grammar, cache, editorCallback) {
|
||||||
|
let defaultLanguage = getDefaultLanguageForGrammar(grammar)
|
||||||
|
|
||||||
const promises = []
|
const promises = []
|
||||||
for (const preElement of domFragment.querySelectorAll('pre')) {
|
|
||||||
const codeBlock =
|
for (const preElement of element.querySelectorAll('pre')) {
|
||||||
preElement.firstElementChild != null
|
const codeBlock = preElement.firstElementChild ?? preElement
|
||||||
? preElement.firstElementChild
|
|
||||||
: preElement
|
|
||||||
const className = codeBlock.getAttribute('class')
|
const className = codeBlock.getAttribute('class')
|
||||||
const fenceName =
|
const fenceName = className?.replace(/^language-/, '') ?? defaultLanguage
|
||||||
className != null ? className.replace(/^language-/, '') : defaultLanguage
|
let editorText = codeBlock.textContent.replace(/\r?\n$/, '')
|
||||||
|
|
||||||
const editor = new TextEditor({
|
// If this PRE element was present in the last render, then we should
|
||||||
readonly: true,
|
// already have a cached text editor available for use.
|
||||||
keyboardInputEnabled: false
|
let editor = cache?.getEditor(preElement) ?? null
|
||||||
})
|
let editorElement
|
||||||
const editorElement = editor.getElement()
|
if (!editor) {
|
||||||
|
editor = new TextEditor({ keyboardInputEnabled: false })
|
||||||
|
editorElement = editor.getElement()
|
||||||
|
editor.setReadOnly(true)
|
||||||
|
cache?.addEditor(preElement, editor)
|
||||||
|
} else {
|
||||||
|
editorElement = editor.getElement()
|
||||||
|
}
|
||||||
|
|
||||||
preElement.classList.add('editor-colors', `lang-${fenceName}`)
|
// If the PRE changed its content, we need to change the content of its
|
||||||
editorElement.setUpdatedSynchronously(true)
|
// `TextEditor`.
|
||||||
preElement.innerHTML = ''
|
if (editor.getText() !== editorText) {
|
||||||
preElement.parentNode.insertBefore(editorElement, preElement)
|
editor.setReadOnly(false)
|
||||||
editor.setText(codeBlock.textContent.replace(/\r?\n$/, ''))
|
editor.setText(editorText)
|
||||||
atom.grammars.assignLanguageMode(editor, scopeForFenceName(fenceName))
|
editor.setReadOnly(true)
|
||||||
editor.setVisible(true)
|
}
|
||||||
|
|
||||||
|
// If the PRE changed its language, we need to change the language of its
|
||||||
|
// `TextEditor`.
|
||||||
|
let scopeDescriptor = editor.getRootScopeDescriptor()[0]
|
||||||
|
let languageScope = scopeForFenceName(fenceName)
|
||||||
|
if (languageScope !== scopeDescriptor && `.${languageScope}` !== scopeDescriptor) {
|
||||||
|
reassignEditorToLanguage(editor, languageScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the editor is brand new, we'll have to insert it; otherwise it should
|
||||||
|
// already be in the right place.
|
||||||
|
if (!editorElement.parentNode) {
|
||||||
|
preElement.parentNode.insertBefore(editorElement, preElement)
|
||||||
|
editor.setVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
promises.push(editorCallback(editorElement, preElement))
|
promises.push(editorCallback(editorElement, preElement))
|
||||||
}
|
}
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
}
|
}
|
||||||
|
|
||||||
var makeAtomEditorNonInteractive = function (editorElement, preElement) {
|
function makeAtomEditorNonInteractive(editorElement) {
|
||||||
preElement.remove()
|
editorElement.setAttributeNode(document.createAttribute('gutter-hidden'))
|
||||||
editorElement.setAttributeNode(document.createAttribute('gutter-hidden')) // Hide gutter
|
editorElement.removeAttribute('tabindex')
|
||||||
editorElement.removeAttribute('tabindex') // Make read-only
|
|
||||||
|
|
||||||
// Remove line decorations from code blocks.
|
// Remove line decorations from code blocks.
|
||||||
for (const cursorLineDecoration of editorElement.getModel()
|
for (const cursorLineDecoration of editorElement.getModel()
|
||||||
@ -276,9 +367,12 @@ var makeAtomEditorNonInteractive = function (editorElement, preElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var convertAtomEditorToStandardElement = (editorElement, preElement) => {
|
function convertAtomEditorToStandardElement(editorElement, preElement) {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
const editor = editorElement.getModel()
|
const editor = editorElement.getModel()
|
||||||
|
// In this code path, we're transplanting the highlighted editor HTML into
|
||||||
|
// the existing `pre` element, so we should empty its contents first.
|
||||||
|
preElement.innerHTML = ''
|
||||||
const done = () =>
|
const done = () =>
|
||||||
editor.component.getNextUpdatePromise().then(function () {
|
editor.component.getNextUpdatePromise().then(function () {
|
||||||
for (const line of editorElement.querySelectorAll(
|
for (const line of editorElement.querySelectorAll(
|
||||||
|
14
packages/markdown-preview/package-lock.json
generated
14
packages/markdown-preview/package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"fs-plus": "^3.0.0",
|
"fs-plus": "^3.0.0",
|
||||||
"github-markdown-css": "^5.5.1",
|
"github-markdown-css": "^5.5.1",
|
||||||
"marked": "5.0.3",
|
"marked": "5.0.3",
|
||||||
|
"morphdom": "^2.7.2",
|
||||||
"underscore-plus": "^1.0.0",
|
"underscore-plus": "^1.0.0",
|
||||||
"yaml-front-matter": "^4.1.1"
|
"yaml-front-matter": "^4.1.1"
|
||||||
},
|
},
|
||||||
@ -23,7 +24,8 @@
|
|||||||
"temp": "^0.8.1"
|
"temp": "^0.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"atom": "*"
|
"atom": "*",
|
||||||
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
@ -278,6 +280,11 @@
|
|||||||
"mkdirp": "bin/cmd.js"
|
"mkdirp": "bin/cmd.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/morphdom": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Dqb/lHFyTi7SZpY0a5R4I/0Edo+iPMbaUexsHHsLAByyixCDiLHPHyVoKVmrpL0THcT7V9Cgev9y21TQYq6wQg=="
|
||||||
|
},
|
||||||
"node_modules/nth-check": {
|
"node_modules/nth-check": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
@ -567,6 +574,11 @@
|
|||||||
"minimist": "0.0.8"
|
"minimist": "0.0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"morphdom": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Dqb/lHFyTi7SZpY0a5R4I/0Edo+iPMbaUexsHHsLAByyixCDiLHPHyVoKVmrpL0THcT7V9Cgev9y21TQYq6wQg=="
|
||||||
|
},
|
||||||
"nth-check": {
|
"nth-check": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
@ -6,7 +6,8 @@
|
|||||||
"repository": "https://github.com/pulsar-edit/pulsar",
|
"repository": "https://github.com/pulsar-edit/pulsar",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"atom": "*"
|
"atom": "*",
|
||||||
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate-github-markdown-css": "node scripts/generate-github-markdown-css.js"
|
"generate-github-markdown-css": "node scripts/generate-github-markdown-css.js"
|
||||||
@ -19,6 +20,7 @@
|
|||||||
"fs-plus": "^3.0.0",
|
"fs-plus": "^3.0.0",
|
||||||
"github-markdown-css": "^5.5.1",
|
"github-markdown-css": "^5.5.1",
|
||||||
"marked": "5.0.3",
|
"marked": "5.0.3",
|
||||||
|
"morphdom": "^2.7.2",
|
||||||
"underscore-plus": "^1.0.0",
|
"underscore-plus": "^1.0.0",
|
||||||
"yaml-front-matter": "^4.1.1"
|
"yaml-front-matter": "^4.1.1"
|
||||||
},
|
},
|
||||||
|
@ -41,6 +41,13 @@ describe('Markdown Preview', function () {
|
|||||||
.getActiveItem())
|
.getActiveItem())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
waitsFor(
|
||||||
|
'preview to finish loading',
|
||||||
|
() => {
|
||||||
|
return !preview.element.classList.contains('loading')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
runs(() => {
|
runs(() => {
|
||||||
expect(preview).toBeInstanceOf(MarkdownPreviewView)
|
expect(preview).toBeInstanceOf(MarkdownPreviewView)
|
||||||
expect(preview.getPath()).toBe(
|
expect(preview.getPath()).toBe(
|
||||||
|
@ -198,12 +198,15 @@ function f(x) {
|
|||||||
() => renderSpy.callCount === 1
|
() => renderSpy.callCount === 1
|
||||||
)
|
)
|
||||||
|
|
||||||
runs(function () {
|
waitsFor(
|
||||||
const rubyEditor = preview.element.querySelector(
|
'atom-text-editor to reassign all language modes after re-render',
|
||||||
"atom-text-editor[data-grammar='source ruby']"
|
() => {
|
||||||
)
|
let rubyEditor = preview.element.querySelector(
|
||||||
expect(rubyEditor).toBeNull()
|
"atom-text-editor[data-grammar='source ruby']"
|
||||||
})
|
)
|
||||||
|
return rubyEditor == null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
waitsForPromise(() => atom.packages.activatePackage('language-ruby'))
|
waitsForPromise(() => atom.packages.activatePackage('language-ruby'))
|
||||||
|
|
||||||
|
@ -2,6 +2,14 @@
|
|||||||
// Global Markdown Preview styles
|
// Global Markdown Preview styles
|
||||||
|
|
||||||
.markdown-preview {
|
.markdown-preview {
|
||||||
|
contain: paint;
|
||||||
|
|
||||||
|
// Hide a `pre` that comes directly after an `atom-text-editor` because the
|
||||||
|
// `atom-text-editor` is the syntax-highlighted representation.
|
||||||
|
atom-text-editor + pre {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
atom-text-editor {
|
atom-text-editor {
|
||||||
// only show scrollbars on hover
|
// only show scrollbars on hover
|
||||||
.scrollbars-visible-always & {
|
.scrollbars-visible-always & {
|
||||||
@ -28,14 +36,38 @@
|
|||||||
.task-list-item {
|
.task-list-item {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
// `.loading` on the preview element automatically shows the spinner/text.
|
||||||
|
// We add a slight animation delay so that, when the preview content is
|
||||||
|
// quick to appear (as usually happens), the spinner won't be shown. It
|
||||||
|
// only shows up when preview content takes a while to render.
|
||||||
|
&:before {
|
||||||
|
display: block;
|
||||||
|
content: 'Loading Markdown…';
|
||||||
|
margin: auto;
|
||||||
|
background-image: url(images/octocat-spinner-128.gif);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 64px;
|
||||||
|
background-position: top center;
|
||||||
|
padding-top: 70px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0;
|
||||||
|
animation-duration: 1s;
|
||||||
|
animation-name: appear-after-short-delay;
|
||||||
|
animation-delay: 0.75s;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-spinner {
|
// Not an actual animation; we just use an animation so that it can appear
|
||||||
margin: auto;
|
// after a short delay.
|
||||||
background-image: url(images/octocat-spinner-128.gif);
|
@keyframes appear-after-short-delay {
|
||||||
background-repeat: no-repeat;
|
0% { opacity: 1; }
|
||||||
background-size: 64px;
|
100% { opacity: 1; }
|
||||||
background-position: top center;
|
|
||||||
padding-top: 70px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
const dedent = require('dedent');
|
||||||
|
|
||||||
describe("Renders Markdown", () => {
|
describe("Renders Markdown", () => {
|
||||||
describe("properly when given no opts", () => {
|
describe("properly when given no opts", () => {
|
||||||
@ -7,6 +8,26 @@ describe("Renders Markdown", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`escapes HTML in code blocks properly`, () => {
|
||||||
|
let input = dedent`
|
||||||
|
Lorem ipsum dolor.
|
||||||
|
|
||||||
|
\`\`\`html
|
||||||
|
<p>sit amet</p>
|
||||||
|
\`\`\`
|
||||||
|
`
|
||||||
|
|
||||||
|
let expected = dedent`
|
||||||
|
<p>Lorem ipsum dolor.</p>
|
||||||
|
<pre><code class="language-html"><p>sit amet</p>
|
||||||
|
</code></pre>
|
||||||
|
`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
atom.ui.markdown.render(input).trim()
|
||||||
|
).toBe(expected);
|
||||||
|
})
|
||||||
|
|
||||||
describe("transforms links correctly", () => {
|
describe("transforms links correctly", () => {
|
||||||
it("makes no changes to a fqdn link", () => {
|
it("makes no changes to a fqdn link", () => {
|
||||||
expect(atom.ui.markdown.render("[Hello World](https://github.com)"))
|
expect(atom.ui.markdown.render("[Hello World](https://github.com)"))
|
||||||
|
@ -249,8 +249,8 @@ function renderMarkdown(content, givenOpts = {}) {
|
|||||||
|
|
||||||
// Here we can add some simple additions that make code highlighting possible later on,
|
// Here we can add some simple additions that make code highlighting possible later on,
|
||||||
// but doesn't actually preform any code highlighting.
|
// but doesn't actually preform any code highlighting.
|
||||||
md.options.highlight = function(str, lang) {
|
md.options.highlight = function (str, lang) {
|
||||||
return `<pre><code class="language-${lang}">${str}</code></pre>`;
|
return `<pre><code class="language-${lang}">${md.utils.escapeHtml(str)}</code></pre>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process disables
|
// Process disables
|
||||||
|
Loading…
Reference in New Issue
Block a user