diff --git a/browser/lib/markdown-toc-generator.js b/browser/lib/markdown-toc-generator.js new file mode 100644 index 00000000..716be83a --- /dev/null +++ b/browser/lib/markdown-toc-generator.js @@ -0,0 +1,100 @@ +/** + * @fileoverview Markdown table of contents generator + */ + +import toc from 'markdown-toc' +import diacritics from 'diacritics-map' +import stripColor from 'strip-color' + +const EOL = require('os').EOL + +/** + * @caseSensitiveSlugify Custom slugify function + * Same implementation that the original used by markdown-toc (node_modules/markdown-toc/lib/utils.js), + * but keeps original case to properly handle https://github.com/BoostIO/Boostnote/issues/2067 + */ +function caseSensitiveSlugify (str) { + function replaceDiacritics (str) { + return str.replace(/[À-ž]/g, function (ch) { + return diacritics[ch] || ch + }) + } + + function getTitle (str) { + if (/^\[[^\]]+\]\(/.test(str)) { + var m = /^\[([^\]]+)\]/.exec(str) + if (m) return m[1] + } + return str + } + + str = getTitle(str) + str = stripColor(str) + // str = str.toLowerCase() //let's be case sensitive + + // `.split()` is often (but not always) faster than `.replace()` + str = str.split(' ').join('-') + str = str.split(/\t/).join('--') + str = str.split(/<\/?[^>]+>/).join('') + str = str.split(/[|$&`~=\\\/@+*!?({[\]})<>=.,;:'"^]/).join('') + str = str.split(/[。?!,、;:“”【】()〔〕[]﹃﹄“ ”‘’﹁﹂—…-~《》〈〉「」]/).join('') + str = replaceDiacritics(str) + return str +} + +const TOC_MARKER_START = '' +const TOC_MARKER_END = '' + +/** + * Takes care of proper updating given editor with TOC. + * If TOC doesn't exit in the editor, it's inserted at current caret position. + * Otherwise,TOC is updated in place. + * @param editor CodeMirror editor to be updated with TOC + */ +export function generateInEditor (editor) { + const tocRegex = new RegExp(`${TOC_MARKER_START}[\\s\\S]*?${TOC_MARKER_END}`) + + function tocExistsInEditor () { + return tocRegex.test(editor.getValue()) + } + + function updateExistingToc () { + const toc = generate(editor.getValue()) + const search = editor.getSearchCursor(tocRegex) + while (search.findNext()) { + search.replace(toc) + } + } + + function addTocAtCursorPosition () { + const toc = generate(editor.getRange(editor.getCursor(), {line: Infinity})) + editor.replaceRange(wrapTocWithEol(toc, editor), editor.getCursor()) + } + + if (tocExistsInEditor()) { + updateExistingToc() + } else { + addTocAtCursorPosition() + } +} + +/** + * Generates MD TOC based on MD document passed as string. + * @param markdownText MD document + * @returns generatedTOC String containing generated TOC + */ +export function generate (markdownText) { + const generatedToc = toc(markdownText, {slugify: caseSensitiveSlugify}) + return TOC_MARKER_START + EOL + EOL + generatedToc.content + EOL + EOL + TOC_MARKER_END +} + +function wrapTocWithEol (toc, editor) { + const leftWrap = editor.getCursor().ch === 0 ? '' : EOL + const rightWrap = editor.getLine(editor.getCursor().line).length === editor.getCursor().ch ? '' : EOL + return leftWrap + toc + rightWrap +} + +export default { + generate, + generateInEditor +} diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js index 9aca81be..e4493a80 100755 --- a/browser/main/Detail/MarkdownNoteDetail.js +++ b/browser/main/Detail/MarkdownNoteDetail.js @@ -29,6 +29,7 @@ import { formatDate } from 'browser/lib/date-formatter' import { getTodoPercentageOfCompleted } from 'browser/lib/getTodoStatus' import striptags from 'striptags' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' +import markdownToc from 'browser/lib/markdown-toc-generator' class MarkdownNoteDetail extends React.Component { constructor (props) { @@ -47,6 +48,7 @@ class MarkdownNoteDetail extends React.Component { this.dispatchTimer = null this.toggleLockButton = this.handleToggleLockButton.bind(this) + this.generateToc = () => this.handleGenerateToc() } focus () { @@ -59,6 +61,7 @@ class MarkdownNoteDetail extends React.Component { const reversedType = this.state.editorType === 'SPLIT' ? 'EDITOR_PREVIEW' : 'SPLIT' this.handleSwitchMode(reversedType) }) + ee.on('code:generate-toc', this.generateToc) } componentWillReceiveProps (nextProps) { @@ -75,6 +78,7 @@ class MarkdownNoteDetail extends React.Component { componentWillUnmount () { ee.off('topbar:togglelockbutton', this.toggleLockButton) + ee.off('code:generate-toc', this.generateToc) if (this.saveQueue != null) this.saveNow() } @@ -262,6 +266,11 @@ class MarkdownNoteDetail extends React.Component { } } + handleGenerateToc () { + const editor = this.refs.content.refs.code.editor + markdownToc.generateInEditor(editor) + } + handleFocus (e) { this.focus() } diff --git a/browser/main/Detail/SnippetNoteDetail.js b/browser/main/Detail/SnippetNoteDetail.js index 3564d6bf..fefba2ce 100644 --- a/browser/main/Detail/SnippetNoteDetail.js +++ b/browser/main/Detail/SnippetNoteDetail.js @@ -29,6 +29,7 @@ import InfoPanelTrashed from './InfoPanelTrashed' import { formatDate } from 'browser/lib/date-formatter' import i18n from 'browser/lib/i18n' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' +import markdownToc from 'browser/lib/markdown-toc-generator' const electron = require('electron') const { remote } = electron @@ -52,6 +53,7 @@ class SnippetNoteDetail extends React.Component { } this.scrollToNextTabThreshold = 0.7 + this.generateToc = () => this.handleGenerateToc() } componentDidMount () { @@ -65,6 +67,7 @@ class SnippetNoteDetail extends React.Component { enableLeftArrow: allTabs.offsetLeft !== 0 }) } + ee.on('code:generate-toc', this.generateToc) } componentWillReceiveProps (nextProps) { @@ -91,6 +94,16 @@ class SnippetNoteDetail extends React.Component { componentWillUnmount () { if (this.saveQueue != null) this.saveNow() + ee.off('code:generate-toc', this.generateToc) + } + + handleGenerateToc () { + const { note, snippetIndex } = this.state + const currentMode = note.snippets[snippetIndex].mode + if (currentMode.includes('Markdown')) { + const currentEditor = this.refs[`code-${snippetIndex}`].refs.code.editor + markdownToc.generateInEditor(currentEditor) + } } handleChange (e) { @@ -441,7 +454,7 @@ class SnippetNoteDetail extends React.Component { const isSuper = global.process.platform === 'darwin' ? e.metaKey : e.ctrlKey - if (isSuper && !e.shiftKey) { + if (isSuper && !e.shiftKey && !e.altKey) { e.preventDefault() this.addSnippet() } diff --git a/lib/main-menu.js b/lib/main-menu.js index cda964c5..fed5eb15 100644 --- a/lib/main-menu.js +++ b/lib/main-menu.js @@ -145,6 +145,16 @@ const file = { { type: 'separator' }, + { + label: 'Generate/Update Markdown TOC', + accelerator: 'Shift+Ctrl+T', + click () { + mainWindow.webContents.send('code:generate-toc') + } + }, + { + type: 'separator' + }, { label: 'Print', accelerator: 'CommandOrControl+P', diff --git a/package.json b/package.json index f9d653c6..59a9d788 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "markdown-it-named-headers": "^0.0.4", "markdown-it-plantuml": "^1.1.0", "markdown-it-smartarrows": "^1.0.1", + "markdown-toc": "^1.2.0", "mdurl": "^1.0.1", "mermaid": "^8.0.0-rc.8", "moment": "^2.10.3", diff --git a/tests/helpers/setup-browser-env.js b/tests/helpers/setup-browser-env.js index d7615c01..3e3232b7 100644 --- a/tests/helpers/setup-browser-env.js +++ b/tests/helpers/setup-browser-env.js @@ -1,5 +1,23 @@ import browserEnv from 'browser-env' -browserEnv(['window', 'document']) +browserEnv(['window', 'document', 'navigator']) + +// for CodeMirror mockup +document.body.createTextRange = function () { + return { + setEnd: function () {}, + setStart: function () {}, + getBoundingClientRect: function () { + return {right: 0} + }, + getClientRects: function () { + return { + length: 0, + left: 0, + right: 0 + } + } + } +} window.localStorage = { // polyfill diff --git a/tests/lib/markdown-toc-generator-test.js b/tests/lib/markdown-toc-generator-test.js new file mode 100644 index 00000000..60568741 --- /dev/null +++ b/tests/lib/markdown-toc-generator-test.js @@ -0,0 +1,668 @@ +/** + * @fileoverview Unit test for browser/lib/markdown-toc-generator + */ + +import CodeMirror from 'codemirror' +require('codemirror/addon/search/searchcursor.js') +const test = require('ava') +const markdownToc = require('browser/lib/markdown-toc-generator') +const EOL = require('os').EOL + +test(t => { + /** + * Contains array of test cases in format : + * [ + * test title + * input markdown, + * expected toc + * ] + * @type {*[]} + */ + const testCases = [ + [ + '***************************** empty note', + ` + `, + ` + + + + + + ` + ], + [ + '***************************** single level', + ` +# one + `, + ` + + +- [one](#one) + + + ` + ], + [ + '***************************** two levels', + ` +# one +# two + `, + ` + + +- [one](#one) +- [two](#two) + + + ` + ], + [ + '***************************** 3 levels with children', + ` +# one +## one one +# two +## two two +# three +## three three + `, + ` + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) +- [three](#three) + * [three three](#three-three) + + + ` + ], + [ + '***************************** 3 levels, 3rd with 6 sub-levels', + ` +# one +## one one +# two +## two two +# three +## three three +### three three three +#### three three three three +##### three three three three three +###### three three three three three three + `, + ` + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) +- [three](#three) + * [three three](#three-three) + + [three three three](#three-three-three) + - [three three three three](#three-three-three-three) + * [three three three three three](#three-three-three-three-three) + + [three three three three three three](#three-three-three-three-three-three) + + + ` + ], + [ + '***************************** multilevel with texts in between', + ` +# one +this is a level one text +this is a level one text +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text +# three + this is a level three three text + this is a level three three text +## three three + this is a text + this is a text +### three three three + this is a text + this is a text +### three three three 2 + this is a text + this is a text +#### three three three three + this is a text + this is a text +#### three three three three 2 + this is a text + this is a text +##### three three three three three + this is a text + this is a text +##### three three three three three 2 + this is a text + this is a text +###### three three three three three three + this is a text + this is a text + this is a text + `, + ` + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) +- [three](#three) + * [three three](#three-three) + + [three three three](#three-three-three) + + [three three three 2](#three-three-three-2) + - [three three three three](#three-three-three-three) + - [three three three three 2](#three-three-three-three-2) + * [three three three three three](#three-three-three-three-three) + * [three three three three three 2](#three-three-three-three-three-2) + + [three three three three three three](#three-three-three-three-three-three) + + + ` + ], + [ + '***************************** outdated TOC', + ` + + +- [one](#one) + * [one one](#one-one) + + + +# one modified +## one one + `, + ` + + +- [one modified](#one-modified) + * [one one](#one-one) + + + ` + ], + [ + '***************************** properly generated case sensitive TOC', + ` +# onE +## oNe one + `, + ` + + +- [onE](#onE) + * [oNe one](#oNe-one) + + + ` + ], + [ + '***************************** position of TOC is stable (do not use elements above toc marker)', + ` +# title + +this is a text + + + +- [onE](#onE) + * [oNe one](#oNe-one) + + + +# onE +## oNe one + `, + ` + + +- [onE](#onE) + * [oNe one](#oNe-one) + + + ` + ], + [ + '***************************** properly handle generation of not completed TOC', + ` +# hoge + +## + `, + ` + + +- [hoge](#hoge) + + + ` + ] + ] + + testCases.forEach(testCase => { + const title = testCase[0] + const inputMd = testCase[1].trim() + const expectedToc = testCase[2].trim() + const generatedToc = markdownToc.generate(inputMd) + + t.is(generatedToc, expectedToc, `generate test : ${title} , generated : ${EOL}${generatedToc}, expected : ${EOL}${expectedToc}`) + }) +}) + +test(t => { + /** + * Contains array of test cases in format : + * [ + * title + * cursor + * inputMd + * expectedMd + * ] + * @type {*[]} + */ + const testCases = [ + [ + `***************************** Empty note, cursor at the top`, + {line: 0, ch: 0}, + ``, + ` + + + + + + ` + ], + [ + `***************************** Two level note,TOC at the beginning `, + {line: 0, ch: 0}, + ` +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + `, + ` + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + ` + ], + [ + `***************************** Two level note, cursor just after 'header text' `, + {line: 1, ch: 12}, + ` +# header + header text + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + `, + ` +# header + header text + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + ` + ], + [ + `***************************** Two level note, cursor at empty line under 'header text' `, + {line: 2, ch: 0}, + ` +# header + header text + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + `, + ` +# header + header text + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + ` + ], + [ + `***************************** Two level note, cursor just before 'text' word`, + {line: 1, ch: 8}, + ` +# header + header text + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + `, + ` +# header + header + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + +text + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + ` + ], + [ + `***************************** Already generated TOC without header file, regenerate TOC in place, no changes`, + {line: 13, ch: 0}, + ` +# header + header text + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + `, + ` +# header + header text + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + +# one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + ` + ], + [ + `***************************** Already generated TOC, needs updating in place`, + {line: 0, ch: 0}, + ` +# header + header text + + +- [one](#one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + +# This is the one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + `, + ` +# header + header text + + +- [This is the one](#This-is-the-one) + * [one one](#one-one) +- [two](#two) + * [two two](#two-two) + + +# This is the one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + ` + ], + [ + `***************************** Document with cursor at the last line, expecting empty TOC `, + {line: 13, ch: 30}, + ` +# header + header text + +# This is the one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + `, + ` +# header + header text + +# This is the one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + + + + + + ` + ], + [ + `***************************** Empty, not actual TOC , should be supplemented with two new points beneath`, + {line: 0, ch: 0}, + ` +# header + header text + +# This is the one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + + + + + +# new point included in toc +## new subpoint + `, + ` +# header + header text + +# This is the one +this is a level one text +this is a level one text + +## one one +# two + this is a level two text + this is a level two text +## two two + this is a level two two text + this is a level two two text + + +- [new point included in toc](#new-point-included-in-toc) + * [new subpoint](#new-subpoint) + + +# new point included in toc +## new subpoint + ` + ] + ] + testCases.forEach(testCase => { + const title = testCase[0] + const cursor = testCase[1] + const inputMd = testCase[2].trim() + const expectedMd = testCase[3].trim() + + const editor = CodeMirror() + editor.setValue(inputMd) + editor.setCursor(cursor) + markdownToc.generateInEditor(editor) + + t.is(expectedMd, editor.getValue(), `generateInEditor test : ${title} , generated : ${EOL}${editor.getValue()}, expected : ${EOL}${expectedMd}`) + }) +}) diff --git a/webpack-skeleton.js b/webpack-skeleton.js index 21d75503..87ea206f 100644 --- a/webpack-skeleton.js +++ b/webpack-skeleton.js @@ -41,6 +41,7 @@ var config = { 'markdown-it-kbd', 'markdown-it-plantuml', 'markdown-it-admonition', + 'markdown-toc', 'devtron', '@rokt33r/season', {