Developing a legal IDE based on Atom
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

513 lines
14 KiB

  1. const path = require('path')
  2. const { Emitter, Disposable, CompositeDisposable, File } = require('atom')
  3. const _ = require('underscore-plus')
  4. const fs = require('fs-plus')
  5. const renderer = require('./renderer')
  6. module.exports = class MarkdownPreviewView {
  7. static deserialize (params) {
  8. return new MarkdownPreviewView(params)
  9. }
  10. constructor ({ editorId, filePath }) {
  11. this.editorId = editorId
  12. this.filePath = filePath
  13. this.element = document.createElement('div')
  14. this.element.classList.add('markdown-preview')
  15. this.element.tabIndex = -1
  16. this.emitter = new Emitter()
  17. this.loaded = false
  18. this.disposables = new CompositeDisposable()
  19. this.registerScrollCommands()
  20. if (this.editorId != null) {
  21. this.resolveEditor(this.editorId)
  22. } else if (atom.packages.hasActivatedInitialPackages()) {
  23. this.subscribeToFilePath(this.filePath)
  24. } else {
  25. this.disposables.add(
  26. atom.packages.onDidActivateInitialPackages(() => {
  27. this.subscribeToFilePath(this.filePath)
  28. })
  29. )
  30. }
  31. }
  32. serialize () {
  33. return {
  34. deserializer: 'MarkdownPreviewView',
  35. filePath: this.getPath() != null ? this.getPath() : this.filePath,
  36. editorId: this.editorId
  37. }
  38. }
  39. copy () {
  40. return new MarkdownPreviewView({
  41. editorId: this.editorId,
  42. filePath: this.getPath() != null ? this.getPath() : this.filePath
  43. })
  44. }
  45. destroy () {
  46. this.disposables.dispose()
  47. this.element.remove()
  48. }
  49. registerScrollCommands () {
  50. this.disposables.add(
  51. atom.commands.add(this.element, {
  52. 'core:move-up': () => {
  53. this.element.scrollTop -= document.body.offsetHeight / 20
  54. },
  55. 'core:move-down': () => {
  56. this.element.scrollTop += document.body.offsetHeight / 20
  57. },
  58. 'core:page-up': () => {
  59. this.element.scrollTop -= this.element.offsetHeight
  60. },
  61. 'core:page-down': () => {
  62. this.element.scrollTop += this.element.offsetHeight
  63. },
  64. 'core:move-to-top': () => {
  65. this.element.scrollTop = 0
  66. },
  67. 'core:move-to-bottom': () => {
  68. this.element.scrollTop = this.element.scrollHeight
  69. }
  70. })
  71. )
  72. }
  73. onDidChangeTitle (callback) {
  74. return this.emitter.on('did-change-title', callback)
  75. }
  76. onDidChangeModified (callback) {
  77. // No op to suppress deprecation warning
  78. return new Disposable()
  79. }
  80. onDidChangeMarkdown (callback) {
  81. return this.emitter.on('did-change-markdown', callback)
  82. }
  83. subscribeToFilePath (filePath) {
  84. this.file = new File(filePath)
  85. this.emitter.emit('did-change-title')
  86. this.disposables.add(
  87. this.file.onDidRename(() => this.emitter.emit('did-change-title'))
  88. )
  89. this.handleEvents()
  90. return this.renderMarkdown()
  91. }
  92. resolveEditor (editorId) {
  93. const resolve = () => {
  94. this.editor = this.editorForId(editorId)
  95. if (this.editor != null) {
  96. this.emitter.emit('did-change-title')
  97. this.disposables.add(
  98. this.editor.onDidDestroy(() =>
  99. this.subscribeToFilePath(this.getPath())
  100. )
  101. )
  102. this.handleEvents()
  103. this.renderMarkdown()
  104. } else {
  105. this.subscribeToFilePath(this.filePath)
  106. }
  107. }
  108. if (atom.packages.hasActivatedInitialPackages()) {
  109. resolve()
  110. } else {
  111. this.disposables.add(atom.packages.onDidActivateInitialPackages(resolve))
  112. }
  113. }
  114. editorForId (editorId) {
  115. for (const editor of atom.workspace.getTextEditors()) {
  116. if (editor.id != null && editor.id.toString() === editorId.toString()) {
  117. return editor
  118. }
  119. }
  120. return null
  121. }
  122. handleEvents () {
  123. const lazyRenderMarkdown = _.debounce(() => this.renderMarkdown(), 250)
  124. this.disposables.add(
  125. atom.grammars.onDidAddGrammar(() => lazyRenderMarkdown())
  126. )
  127. if (typeof atom.grammars.onDidRemoveGrammar === 'function') {
  128. this.disposables.add(
  129. atom.grammars.onDidRemoveGrammar(() => lazyRenderMarkdown())
  130. )
  131. } else {
  132. // TODO: Remove onDidUpdateGrammar hook once onDidRemoveGrammar is released
  133. this.disposables.add(
  134. atom.grammars.onDidUpdateGrammar(() => lazyRenderMarkdown())
  135. )
  136. }
  137. atom.commands.add(this.element, {
  138. 'core:copy': event => {
  139. event.stopPropagation()
  140. return this.copyToClipboard()
  141. },
  142. 'markdown-preview:select-all': () => {
  143. this.selectAll()
  144. },
  145. 'markdown-preview:zoom-in': () => {
  146. const zoomLevel = parseFloat(getComputedStyle(this.element).zoom)
  147. this.element.style.zoom = zoomLevel + 0.1
  148. },
  149. 'markdown-preview:zoom-out': () => {
  150. const zoomLevel = parseFloat(getComputedStyle(this.element).zoom)
  151. this.element.style.zoom = zoomLevel - 0.1
  152. },
  153. 'markdown-preview:reset-zoom': () => {
  154. this.element.style.zoom = 1
  155. },
  156. 'markdown-preview:toggle-break-on-single-newline' () {
  157. const keyPath = 'markdown-preview.breakOnSingleNewline'
  158. atom.config.set(keyPath, !atom.config.get(keyPath))
  159. },
  160. 'markdown-preview:toggle-github-style' () {
  161. const keyPath = 'markdown-preview.useGitHubStyle'
  162. atom.config.set(keyPath, !atom.config.get(keyPath))
  163. }
  164. })
  165. const changeHandler = () => {
  166. this.renderMarkdown()
  167. const pane = atom.workspace.paneForItem(this)
  168. if (pane != null && pane !== atom.workspace.getActivePane()) {
  169. pane.activateItem(this)
  170. }
  171. }
  172. if (this.file) {
  173. this.disposables.add(this.file.onDidChange(changeHandler))
  174. } else if (this.editor) {
  175. this.disposables.add(
  176. this.editor.getBuffer().onDidStopChanging(function () {
  177. if (atom.config.get('markdown-preview.liveUpdate')) {
  178. changeHandler()
  179. }
  180. })
  181. )
  182. this.disposables.add(
  183. this.editor.onDidChangePath(() => this.emitter.emit('did-change-title'))
  184. )
  185. this.disposables.add(
  186. this.editor.getBuffer().onDidSave(function () {
  187. if (!atom.config.get('markdown-preview.liveUpdate')) {
  188. changeHandler()
  189. }
  190. })
  191. )
  192. this.disposables.add(
  193. this.editor.getBuffer().onDidReload(function () {
  194. if (!atom.config.get('markdown-preview.liveUpdate')) {
  195. changeHandler()
  196. }
  197. })
  198. )
  199. }
  200. this.disposables.add(
  201. atom.config.onDidChange(
  202. 'markdown-preview.breakOnSingleNewline',
  203. changeHandler
  204. )
  205. )
  206. this.disposables.add(
  207. atom.config.observe('markdown-preview.useGitHubStyle', useGitHubStyle => {
  208. if (useGitHubStyle) {
  209. this.element.setAttribute('data-use-github-style', '')
  210. } else {
  211. this.element.removeAttribute('data-use-github-style')
  212. }
  213. })
  214. )
  215. document.onselectionchange = () => {
  216. const selection = window.getSelection()
  217. const selectedNode = selection.baseNode
  218. if (
  219. selectedNode === null ||
  220. this.element === selectedNode ||
  221. this.element.contains(selectedNode)
  222. ) {
  223. if (selection.isCollapsed) {
  224. this.element.classList.remove('has-selection')
  225. } else {
  226. this.element.classList.add('has-selection')
  227. }
  228. }
  229. }
  230. }
  231. renderMarkdown () {
  232. if (!this.loaded) {
  233. this.showLoading()
  234. }
  235. return this.getMarkdownSource()
  236. .then(source => {
  237. if (source != null) {
  238. return this.renderMarkdownText(source)
  239. }
  240. })
  241. .catch(reason => this.showError({ message: reason }))
  242. }
  243. getMarkdownSource () {
  244. if (this.file && this.file.getPath()) {
  245. return this.file
  246. .read()
  247. .then(source => {
  248. if (source === null) {
  249. return Promise.reject(
  250. new Error(`${this.file.getBaseName()} could not be found`)
  251. )
  252. } else {
  253. return Promise.resolve(source)
  254. }
  255. })
  256. .catch(reason => Promise.reject(reason))
  257. } else if (this.editor != null) {
  258. return Promise.resolve(this.editor.getText())
  259. } else {
  260. return Promise.reject(new Error('No editor found'))
  261. }
  262. }
  263. async getHTML () {
  264. const source = await this.getMarkdownSource()
  265. if (source == null) {
  266. return
  267. }
  268. return renderer.toHTML(source, this.getPath(), this.getGrammar())
  269. }
  270. async renderMarkdownText (text) {
  271. const { scrollTop } = this.element
  272. try {
  273. const domFragment = await renderer.toDOMFragment(
  274. text,
  275. this.getPath(),
  276. this.getGrammar()
  277. )
  278. this.loading = false
  279. this.loaded = true
  280. this.element.textContent = ''
  281. this.element.appendChild(domFragment)
  282. this.emitter.emit('did-change-markdown')
  283. this.element.scrollTop = scrollTop
  284. } catch (error) {
  285. this.showError(error)
  286. }
  287. }
  288. getTitle () {
  289. if (this.file != null && this.getPath() != null) {
  290. return `${path.basename(this.getPath())} Preview`
  291. } else if (this.editor != null) {
  292. return `${this.editor.getTitle()} Preview`
  293. } else {
  294. return 'Markdown Preview'
  295. }
  296. }
  297. getIconName () {
  298. return 'markdown'
  299. }
  300. getURI () {
  301. if (this.file != null) {
  302. return `markdown-preview://${this.getPath()}`
  303. } else {
  304. return `markdown-preview://editor/${this.editorId}`
  305. }
  306. }
  307. getPath () {
  308. if (this.file != null) {
  309. return this.file.getPath()
  310. } else if (this.editor != null) {
  311. return this.editor.getPath()
  312. }
  313. }
  314. getGrammar () {
  315. return this.editor != null ? this.editor.getGrammar() : undefined
  316. }
  317. getDocumentStyleSheets () {
  318. // This function exists so we can stub it
  319. return document.styleSheets
  320. }
  321. getTextEditorStyles () {
  322. const textEditorStyles = document.createElement('atom-styles')
  323. textEditorStyles.initialize(atom.styles)
  324. textEditorStyles.setAttribute('context', 'atom-text-editor')
  325. document.body.appendChild(textEditorStyles)
  326. // Extract style elements content
  327. return Array.prototype.slice
  328. .apply(textEditorStyles.childNodes)
  329. .map(styleElement => styleElement.innerText)
  330. }
  331. getMarkdownPreviewCSS () {
  332. const markdownPreviewRules = []
  333. const ruleRegExp = /\.markdown-preview/
  334. const cssUrlRegExp = /url\(atom:\/\/markdown-preview\/assets\/(.*)\)/
  335. for (const stylesheet of this.getDocumentStyleSheets()) {
  336. if (stylesheet.rules != null) {
  337. for (const rule of stylesheet.rules) {
  338. // We only need `.markdown-review` css
  339. if (rule.selectorText && rule.selectorText.match(ruleRegExp)) {
  340. markdownPreviewRules.push(rule.cssText)
  341. }
  342. }
  343. }
  344. }
  345. return markdownPreviewRules
  346. .concat(this.getTextEditorStyles())
  347. .join('\n')
  348. .replace(/atom-text-editor/g, 'pre.editor-colors')
  349. .replace(/:host/g, '.host') // Remove shadow-dom :host selector causing problem on FF
  350. .replace(cssUrlRegExp, function (match, assetsName, offset, string) {
  351. // base64 encode assets
  352. const assetPath = path.join(__dirname, '../assets', assetsName)
  353. const originalData = fs.readFileSync(assetPath, 'binary')
  354. const base64Data = Buffer.from(originalData, 'binary').toString(
  355. 'base64'
  356. )
  357. return `url('data:image/jpeg;base64,${base64Data}')`
  358. })
  359. }
  360. showError (result) {
  361. this.element.textContent = ''
  362. const h2 = document.createElement('h2')
  363. h2.textContent = 'Previewing Markdown Failed'
  364. this.element.appendChild(h2)
  365. if (result) {
  366. const h3 = document.createElement('h3')
  367. h3.textContent = result.message
  368. this.element.appendChild(h3)
  369. }
  370. }
  371. showLoading () {
  372. this.loading = true
  373. this.element.textContent = ''
  374. const div = document.createElement('div')
  375. div.classList.add('markdown-spinner')
  376. div.textContent = 'Loading Markdown\u2026'
  377. this.element.appendChild(div)
  378. }
  379. selectAll () {
  380. if (this.loading) {
  381. return
  382. }
  383. const selection = window.getSelection()
  384. selection.removeAllRanges()
  385. const range = document.createRange()
  386. range.selectNodeContents(this.element)
  387. selection.addRange(range)
  388. }
  389. async copyToClipboard () {
  390. if (this.loading) {
  391. return
  392. }
  393. const selection = window.getSelection()
  394. const selectedText = selection.toString()
  395. const selectedNode = selection.baseNode
  396. // Use default copy event handler if there is selected text inside this view
  397. if (
  398. selectedText &&
  399. selectedNode != null &&
  400. (this.element === selectedNode || this.element.contains(selectedNode))
  401. ) {
  402. atom.clipboard.write(selectedText)
  403. } else {
  404. try {
  405. const html = await this.getHTML()
  406. atom.clipboard.write(html)
  407. } catch (error) {
  408. atom.notifications.addError('Copying Markdown as HTML failed', {
  409. dismissable: true,
  410. detail: error.message
  411. })
  412. }
  413. }
  414. }
  415. getSaveDialogOptions () {
  416. let defaultPath = this.getPath()
  417. if (defaultPath) {
  418. defaultPath += '.html'
  419. } else {
  420. let projectPath
  421. defaultPath = 'untitled.md.html'
  422. if ((projectPath = atom.project.getPaths()[0])) {
  423. defaultPath = path.join(projectPath, defaultPath)
  424. }
  425. }
  426. return { defaultPath }
  427. }
  428. async saveAs (htmlFilePath) {
  429. if (this.loading) {
  430. atom.notifications.addWarning(
  431. 'Please wait until the Markdown Preview has finished loading before saving'
  432. )
  433. return
  434. }
  435. const filePath = this.getPath()
  436. let title = 'Markdown to HTML'
  437. if (filePath) {
  438. title = path.parse(filePath).name
  439. }
  440. const htmlBody = await this.getHTML()
  441. const html =
  442. `\
  443. <!DOCTYPE html>
  444. <html>
  445. <head>
  446. <meta charset="utf-8" />
  447. <title>${title}</title>
  448. <style>${this.getMarkdownPreviewCSS()}</style>
  449. </head>
  450. <body class='markdown-preview' data-use-github-style>${htmlBody}</body>
  451. </html>` + '\n' // Ensure trailing newline
  452. fs.writeFileSync(htmlFilePath, html)
  453. return atom.workspace.open(htmlFilePath)
  454. }
  455. }