diff --git a/docs/PREFERENCES.md b/docs/PREFERENCES.md index 5d3f145c..0f3a60e7 100644 --- a/docs/PREFERENCES.md +++ b/docs/PREFERENCES.md @@ -31,6 +31,7 @@ Preferences can be controlled and modified in the settings window or via the `pr | textDirection | String | ltr | The writing text direction, optional value: `ltr` or `rtl` | | codeFontSize | Number | 14 | Font size on code block, the range is 12 ~ 28 | | codeFontFamily | String | `DejaVu Sans Mono` | Code font family | +| codeBlockLineNumbers | Boolean | true | Whether to show the line numbers in code block | | trimUnnecessaryCodeBlockEmptyLines | Boolean | true | Whether to trim the beginning and end empty line in Code block | | hideQuickInsertHint | Boolean | false | Hide hint for quickly creating paragraphs | | imageDropAction | String | folder | The default behavior after paste or drag the image to Mark Text, upload it to the image cloud (if configured), move to the specified folder, insert the path | diff --git a/src/main/preferences/schema.json b/src/main/preferences/schema.json index f14c9976..26e6cf69 100644 --- a/src/main/preferences/schema.json +++ b/src/main/preferences/schema.json @@ -94,6 +94,11 @@ "type": "string", "pattern": "^[_A-z0-9]+((-|\\s)*[_A-z0-9])*$" }, + "codeBlockLineNumbers": { + "description": "Editor--Whether to show the line numbers", + "type": "boolean", + "default": true + }, "trimUnnecessaryCodeBlockEmptyLines": { "description": "Editor--Trim the beginning and ending empty lines in code block", "type": "boolean" diff --git a/src/muya/lib/assets/styles/index.css b/src/muya/lib/assets/styles/index.css index 91734a53..88f0a1aa 100644 --- a/src/muya/lib/assets/styles/index.css +++ b/src/muya/lib/assets/styles/index.css @@ -582,7 +582,7 @@ pre.ag-front-matter span.ag-code-content:first-of-type:empty::after { } pre[data-role$='code'] span.ag-language-input:empty::after { - content: 'Input Language...'; + content: 'Input Language Identifier...'; color: var(--editorColor10); } @@ -621,7 +621,7 @@ pre.ag-indent-code > code::before, pre.ag-fence-code > code::before { content: ''; position: absolute; - top: -10px; + bottom: -1em; right: -5px; color: var(--editorColor30); font-size: 12px; @@ -1157,6 +1157,97 @@ span.ag-reference-link { padding-right: 0; } +.ag-code-copy { + position: absolute; + top: .5em; + right: .5em; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + opacity: 0; + z-index: 1; + transition: opacity .2s ease-in-out; +} + +.ag-active .ag-code-copy { + opacity: .5; +} + +pre:not(.ag-active):hover .ag-code-copy { + opacity: .5; +} + +.ag-code-copy:hover { + opacity: 1; +} + +.ag-code-copy i.icon { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; +} + +.ag-code-copy i.icon > i[class^=icon-] { + width: 100%; + height: 100%; + filter: drop-shadow(16px 0 var(--iconColor)); + position: absolute; + left: -16px; +} + +pre.ag-paragraph.line-numbers { + position: relative; + padding-left: 2.5em; + counter-reset: linenumber; +} + +pre.ag-paragraph.line-numbers > code { + position: relative; + white-space: inherit; +} + +figure:not(.ag-active) pre.ag-paragraph.line-numbers { + display: none; +} + +.line-numbers .line-numbers-rows { + position: absolute; + pointer-events: none; + top: 0; + font-size: 100%; + left: -2.5em; + width: 2.5em; /* works for line-numbers below 1000 lines */ + letter-spacing: -1px; + + user-select: none; + +} + +.line-numbers-sizer { + white-space: pre-line; + word-break: break-all; +} + +.line-numbers-rows > span { + pointer-events: none; + display: block; + counter-increment: linenumber; +} + +.line-numbers-rows > span:before { + content: counter(linenumber); + color: var(--editorColor30); + display: block; + padding-right: .8em; + text-align: right; + transform: scale(.8); + position: relative; + top: .05em; +} + .ag-inline-footnote-identifier { background: var(--codeBlockBgColor); padding: 0 0.4em; diff --git a/src/muya/lib/config/index.js b/src/muya/lib/config/index.js index a5a368bb..fe291b6a 100644 --- a/src/muya/lib/config/index.js +++ b/src/muya/lib/config/index.js @@ -255,6 +255,7 @@ export const MUYA_DEFAULT_OPTION = { bulletListMarker: '-', orderListDelimiter: '.', tabSize: 4, + codeBlockLineNumbers: true, // bullet/list marker width + listIndentation, tab or Daring Fireball Markdown (4 spaces) --> list indentation listIndentation: 1, frontmatterType: '-', diff --git a/src/muya/lib/contentState/codeBlockCtrl.js b/src/muya/lib/contentState/codeBlockCtrl.js index 237db711..f5b71131 100644 --- a/src/muya/lib/contentState/codeBlockCtrl.js +++ b/src/muya/lib/contentState/codeBlockCtrl.js @@ -1,4 +1,5 @@ import { loadLanguage } from '../prism/index' +import resizeCodeBlockLineNumber from '../utils/resizeCodeLineNumber' import selection from '../selection' const CODE_UPDATE_REP = /^`{3,}(.*)/ @@ -123,6 +124,32 @@ const codeBlockCtrl = ContentState => { } return false } + + /** + * Copy the code block by click right-top copy icon in code block. + */ + ContentState.prototype.copyCodeBlock = function (event) { + const { target } = event + const preEle = target.closest('pre') + const preBlock = this.getBlock(preEle.id) + const codeBlock = preBlock.children.find(c => c.type === 'code') + const codeContent = codeBlock.children[0].text + this.muya.clipboard.copy('copyCodeContent', codeContent) + } + + ContentState.prototype.resizeLineNumber = function () { + const { codeBlockLineNumbers } = this.muya.options + if (!codeBlockLineNumbers) { + return + } + + const codeBlocks = document.querySelectorAll('pre.line-numbers') + if (codeBlocks.length) { + for (const ele of codeBlocks) { + resizeCodeBlockLineNumber(ele) + } + } + } } export default codeBlockCtrl diff --git a/src/muya/lib/contentState/copyCutCtrl.js b/src/muya/lib/contentState/copyCutCtrl.js index f0782a57..2395f114 100644 --- a/src/muya/lib/contentState/copyCutCtrl.js +++ b/src/muya/lib/contentState/copyCutCtrl.js @@ -47,6 +47,9 @@ const copyCutCtrl = ContentState => { ContentState.prototype.getClipBoradData = function () { const { start, end } = selection.getCursorRange() + if (!start || !end) { + return { html: '', text: '' } + } if (start.key === end.key) { const startBlock = this.getBlock(start.key) const { type, text, functionType } = startBlock @@ -273,6 +276,15 @@ const copyCutCtrl = ContentState => { event.clipboardData.setData('text/plain', markdown) break } + + case 'copyCodeContent': { + const codeContent = copyInfo + if (typeof codeContent !== 'string') { + return + } + event.clipboardData.setData('text/html', '') + event.clipboardData.setData('text/plain', codeContent) + } } } } diff --git a/src/muya/lib/contentState/index.js b/src/muya/lib/contentState/index.js index 735dd93b..67996403 100644 --- a/src/muya/lib/contentState/index.js +++ b/src/muya/lib/contentState/index.js @@ -205,7 +205,7 @@ class ContentState { } postRender () { - // do nothing. + this.resizeLineNumber() } render (isRenderCursor = true, clearCache = false) { diff --git a/src/muya/lib/eventHandler/clickEvent.js b/src/muya/lib/eventHandler/clickEvent.js index d16c0368..157f2a49 100644 --- a/src/muya/lib/eventHandler/clickEvent.js +++ b/src/muya/lib/eventHandler/clickEvent.js @@ -100,6 +100,7 @@ class ClickEvent { const mathRender = target.closest(`.${CLASS_OR_ID.AG_MATH_RENDER}`) const rubyRender = target.closest(`.${CLASS_OR_ID.AG_RUBY_RENDER}`) const imageWrapper = target.closest(`.${CLASS_OR_ID.AG_INLINE_IMAGE}`) + const codeCopy = target.closest('.ag-code-copy') const footnoteBackLink = target.closest('.ag-footnote-backlink') const imageDelete = target.closest('.ag-image-icon-delete') || target.closest('.ag-image-icon-close') const mathText = mathRender && mathRender.previousElementSibling @@ -116,6 +117,11 @@ class ClickEvent { } else if (rubyText) { selectionText(rubyText) } + if (codeCopy) { + event.stopPropagation() + event.preventDefault() + return this.muya.contentState.copyCodeBlock(event) + } // Handle delete inline iamge by click delete icon. if (imageDelete && imageWrapper) { const imageInfo = getImageInfo(imageWrapper) diff --git a/src/muya/lib/eventHandler/clipboard.js b/src/muya/lib/eventHandler/clipboard.js index dc67c602..fdfd1026 100644 --- a/src/muya/lib/eventHandler/clipboard.js +++ b/src/muya/lib/eventHandler/clipboard.js @@ -60,10 +60,11 @@ class Clipboard { /** * Copy the anchor block(table, paragraph, math block etc) with the info + * @param {string|object} type copyBlock or copyCodeContent * @param {string|object} info is the block key if it's string, or block if it's object */ - copy (info) { - this._copyType = 'copyBlock' + copy (type, info) { + this._copyType = type this._copyInfo = info document.execCommand('copy') } diff --git a/src/muya/lib/eventHandler/resize.js b/src/muya/lib/eventHandler/resize.js new file mode 100644 index 00000000..872885bf --- /dev/null +++ b/src/muya/lib/eventHandler/resize.js @@ -0,0 +1,22 @@ +import resizeCodeBlockLineNumber from '../utils/resizeCodeLineNumber' +import { throttle } from '../utils' + +class Resize { + constructor (muya) { + this.muya = muya + this.listen() + } + + listen () { + window.addEventListener('resize', throttle(() => { + const codeBlocks = document.querySelectorAll('pre.line-numbers') + if (codeBlocks.length) { + for (const ele of codeBlocks) { + resizeCodeBlockLineNumber(ele) + } + } + }, 300)) + } +} + +export default Resize diff --git a/src/muya/lib/index.js b/src/muya/lib/index.js index fd6d480a..c6b03919 100644 --- a/src/muya/lib/index.js +++ b/src/muya/lib/index.js @@ -4,6 +4,7 @@ import MouseEvent from './eventHandler/mouseEvent' import Clipboard from './eventHandler/clipboard' import Keyboard from './eventHandler/keyboard' import DragDrop from './eventHandler/dragDrop' +import Resize from './eventHandler/resize' import ClickEvent from './eventHandler/clickEvent' import { CLASS_OR_ID, MUYA_DEFAULT_OPTION } from './config' import { wordCount } from './utils' @@ -41,6 +42,7 @@ class Muya { this.clickEvent = new ClickEvent(this) this.keyboard = new Keyboard(this) this.dragdrop = new DragDrop(this) + this.resize = new Resize(this) this.mouseEvent = new MouseEvent(this) this.init() } @@ -362,7 +364,7 @@ class Muya { * @param {string|object} key the block key or block */ copy (info) { - return this.clipboard.copy(info) + return this.clipboard.copy('copyBlock', info) } setOptions (options, needRender = false) { diff --git a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js index 9643e106..f17a19d7 100644 --- a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js @@ -2,6 +2,8 @@ import { CLASS_OR_ID } from '../../../config' import { renderTableTools } from './renderToolBar' import { footnoteJumpIcon } from './renderFootnoteJump' import { renderEditIcon } from './renderContainerEditIcon' +import renderLineNumberRows from './renderLineNumber' +import renderCopyButton from './renderCopyButton' import { renderLeftBar, renderBottomBar } from './renderTableDargBar' import { h } from '../snabbdom' @@ -46,8 +48,20 @@ export default function renderContainerBlock (parent, block, activeBlocks, match }) } - if (/code|pre/.test(type) && typeof lang === 'string' && !!lang) { - selector += `.language-${lang.replace(/[#.]{1}/g, '')}` + if (/code|pre/.test(type)) { + if (typeof lang === 'string' && !!lang) { + selector += `.language-${lang.replace(/[#.]{1}/g, '')}` + } + if (type === 'pre') { + children.unshift(renderCopyButton()) + } + if (this.muya.options.codeBlockLineNumbers) { + if (type === 'pre') { + selector += '.line-numbers' + } else { + children.unshift(renderLineNumberRows(block.children[0])) + } + } Object.assign(data.attrs, { spellcheck: 'false' }) } diff --git a/src/muya/lib/parser/render/renderBlock/renderCopyButton.js b/src/muya/lib/parser/render/renderBlock/renderCopyButton.js new file mode 100644 index 00000000..b6847c0c --- /dev/null +++ b/src/muya/lib/parser/render/renderBlock/renderCopyButton.js @@ -0,0 +1,21 @@ +import { h } from '../snabbdom' +import copyIcon from '../../../assets/pngicon/copy/2.png' + +const renderCopyButton = () => { + const selector = 'a.ag-code-copy' + const iconVnode = h('i.icon', h('i.icon-inner', { + style: { + background: `url(${copyIcon}) no-repeat`, + 'background-size': '100%' + } + }, '')) + + return h(selector, { + attrs: { + title: 'Copy content', + contenteditable: 'false' + } + }, iconVnode) +} + +export default renderCopyButton diff --git a/src/muya/lib/parser/render/renderBlock/renderLineNumber.js b/src/muya/lib/parser/render/renderBlock/renderLineNumber.js new file mode 100644 index 00000000..c0ddb7b0 --- /dev/null +++ b/src/muya/lib/parser/render/renderBlock/renderLineNumber.js @@ -0,0 +1,22 @@ +import { h } from '../snabbdom' + +const NEW_LINE_EXP = /\n(?!$)/g + +const renderLineNumberRows = codeContent => { + const { text } = codeContent + const match = text.match(NEW_LINE_EXP) + let linesNum = match ? match.length + 1 : 1 + if (text.endsWith('\n')) { + linesNum++ + } + const data = { + attrs: { + 'aria-hidden': true + } + } + const children = [...new Array(linesNum)].map(() => h('span')) + + return h('span.line-numbers-rows', data, children) +} + +export default renderLineNumberRows diff --git a/src/muya/lib/utils/resizeCodeLineNumber.js b/src/muya/lib/utils/resizeCodeLineNumber.js new file mode 100644 index 00000000..e125abba --- /dev/null +++ b/src/muya/lib/utils/resizeCodeLineNumber.js @@ -0,0 +1,57 @@ +/** + * This file copy from prismjs/plugins/prism-line-number + */ + +/** + * Regular expression used for determining line breaks + * @type {RegExp} + */ +const NEW_LINE_EXP = /\n(?!$)/g + +/** + * Returns style declarations for the element + * @param {Element} element + */ +const getStyles = function (element) { + if (!element) { + return null + } + + return window.getComputedStyle ? getComputedStyle(element) : (element.currentStyle || null) +} + +/** +* Resizes line numbers spans according to height of line of code +* @param {Element} element
 element
+*/
+const resizeCodeBlockLineNumber = function (element) {
+  const codeStyles = getStyles(element)
+  const whiteSpace = codeStyles['white-space']
+
+  if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'pre-line') {
+    const codeElement = element.querySelector('code')
+    const lineNumbersWrapper = element.querySelector('.line-numbers-rows')
+    let lineNumberSizer = element.querySelector('.line-numbers-sizer')
+    const codeLines = codeElement.textContent.split(NEW_LINE_EXP)
+
+    if (!lineNumberSizer) {
+      lineNumberSizer = document.createElement('span')
+      lineNumberSizer.className = 'line-numbers-sizer'
+
+      codeElement.appendChild(lineNumberSizer)
+    }
+
+    lineNumberSizer.style.display = 'block'
+
+    codeLines.forEach(function (line, lineNumber) {
+      lineNumberSizer.textContent = line || '\n'
+      const lineSize = lineNumberSizer.getBoundingClientRect().height
+      lineNumbersWrapper.children[lineNumber].style.height = lineSize + 'px'
+    })
+
+    lineNumberSizer.textContent = ''
+    lineNumberSizer.style.display = 'none'
+  }
+}
+
+export default resizeCodeBlockLineNumber
diff --git a/src/renderer/components/editorWithTabs/editor.vue b/src/renderer/components/editorWithTabs/editor.vue
index 15bb0dc3..94dec795 100644
--- a/src/renderer/components/editorWithTabs/editor.vue
+++ b/src/renderer/components/editorWithTabs/editor.vue
@@ -142,6 +142,7 @@ export default {
       fontSize: state => state.preferences.fontSize,
       codeFontSize: state => state.preferences.codeFontSize,
       codeFontFamily: state => state.preferences.codeFontFamily,
+      codeBlockLineNumbers: state => state.preferences.codeBlockLineNumbers,
       trimUnnecessaryCodeBlockEmptyLines: state => state.preferences.trimUnnecessaryCodeBlockEmptyLines,
       editorFontFamily: state => state.preferences.editorFontFamily,
       hideQuickInsertHint: state => state.preferences.hideQuickInsertHint,
@@ -320,6 +321,12 @@ export default {
         })
       }
     },
+    codeBlockLineNumbers: function (value, oldValue) {
+      const { editor } = this
+      if (value !== oldValue && editor) {
+        editor.setOptions({ codeBlockLineNumbers: value }, true)
+      }
+    },
     codeFontFamily: function (value, oldValue) {
       if (value !== oldValue) {
         addCommonStyle({
@@ -451,6 +458,7 @@ export default {
         tabSize,
         fontSize,
         lineHeight,
+        codeBlockLineNumbers,
         listIndentation,
         frontmatterType,
         superSubScript,
@@ -495,6 +503,7 @@ export default {
         tabSize,
         fontSize,
         lineHeight,
+        codeBlockLineNumbers,
         listIndentation,
         frontmatterType,
         superSubScript,
diff --git a/src/renderer/prefComponents/editor/index.vue b/src/renderer/prefComponents/editor/index.vue
index 28edac3c..033e080b 100644
--- a/src/renderer/prefComponents/editor/index.vue
+++ b/src/renderer/prefComponents/editor/index.vue
@@ -39,6 +39,11 @@
       :value="codeFontFamily"
       :onChange="value => onSelectChange('codeFontFamily', value)"
     >
+    
      state.preferences.textDirection,
       codeFontSize: state => state.preferences.codeFontSize,
       codeFontFamily: state => state.preferences.codeFontFamily,
+      codeBlockLineNumbers: state => state.preferences.codeBlockLineNumbers,
       trimUnnecessaryCodeBlockEmptyLines: state => state.preferences.trimUnnecessaryCodeBlockEmptyLines,
       hideQuickInsertHint: state => state.preferences.hideQuickInsertHint,
       hideLinkPopup: state => state.preferences.hideLinkPopup,
diff --git a/src/renderer/store/preferences.js b/src/renderer/store/preferences.js
index 9c428e66..7555df84 100644
--- a/src/renderer/store/preferences.js
+++ b/src/renderer/store/preferences.js
@@ -20,6 +20,7 @@ const state = {
   lineHeight: 1.6,
   codeFontSize: 14,
   codeFontFamily: 'DejaVu Sans Mono',
+  codeBlockLineNumbers: true,
   trimUnnecessaryCodeBlockEmptyLines: true,
   editorLineWidth: '',
 
diff --git a/static/preference.json b/static/preference.json
index c793da69..6ba50103 100644
--- a/static/preference.json
+++ b/static/preference.json
@@ -16,6 +16,7 @@
   "lineHeight": 1.6,
   "codeFontSize": 14,
   "codeFontFamily": "DejaVu Sans Mono",
+  "codeBlockLineNumbers": true,
   "trimUnnecessaryCodeBlockEmptyLines": true,
   "editorLineWidth": "",