Skip to content

Commit 7ab3ce9

Browse files
authored
Merge pull request BoostIO#2281 from mbarczak/features/toc_generator
Automatic table of contents generation for Markdown
2 parents e040aee + e9070fa commit 7ab3ce9

8 files changed

+822
-2
lines changed

browser/lib/markdown-toc-generator.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @fileoverview Markdown table of contents generator
3+
*/
4+
5+
import toc from 'markdown-toc'
6+
import diacritics from 'diacritics-map'
7+
import stripColor from 'strip-color'
8+
9+
const EOL = require('os').EOL
10+
11+
/**
12+
* @caseSensitiveSlugify Custom slugify function
13+
* Same implementation that the original used by markdown-toc (node_modules/markdown-toc/lib/utils.js),
14+
* but keeps original case to properly handle https://github.com/BoostIO/Boostnote/issues/2067
15+
*/
16+
function caseSensitiveSlugify (str) {
17+
function replaceDiacritics (str) {
18+
return str.replace(/[À-ž]/g, function (ch) {
19+
return diacritics[ch] || ch
20+
})
21+
}
22+
23+
function getTitle (str) {
24+
if (/^\[[^\]]+\]\(/.test(str)) {
25+
var m = /^\[([^\]]+)\]/.exec(str)
26+
if (m) return m[1]
27+
}
28+
return str
29+
}
30+
31+
str = getTitle(str)
32+
str = stripColor(str)
33+
// str = str.toLowerCase() //let's be case sensitive
34+
35+
// `.split()` is often (but not always) faster than `.replace()`
36+
str = str.split(' ').join('-')
37+
str = str.split(/\t/).join('--')
38+
str = str.split(/<\/?[^>]+>/).join('')
39+
str = str.split(/[|$&`~=\\\/@+*!?({[\]})<>=.,;:'"^]/).join('')
40+
str = str.split(/[ ]/).join('')
41+
str = replaceDiacritics(str)
42+
return str
43+
}
44+
45+
const TOC_MARKER_START = '<!-- toc -->'
46+
const TOC_MARKER_END = '<!-- tocstop -->'
47+
48+
/**
49+
* Takes care of proper updating given editor with TOC.
50+
* If TOC doesn't exit in the editor, it's inserted at current caret position.
51+
* Otherwise,TOC is updated in place.
52+
* @param editor CodeMirror editor to be updated with TOC
53+
*/
54+
export function generateInEditor (editor) {
55+
const tocRegex = new RegExp(`${TOC_MARKER_START}[\\s\\S]*?${TOC_MARKER_END}`)
56+
57+
function tocExistsInEditor () {
58+
return tocRegex.test(editor.getValue())
59+
}
60+
61+
function updateExistingToc () {
62+
const toc = generate(editor.getValue())
63+
const search = editor.getSearchCursor(tocRegex)
64+
while (search.findNext()) {
65+
search.replace(toc)
66+
}
67+
}
68+
69+
function addTocAtCursorPosition () {
70+
const toc = generate(editor.getRange(editor.getCursor(), {line: Infinity}))
71+
editor.replaceRange(wrapTocWithEol(toc, editor), editor.getCursor())
72+
}
73+
74+
if (tocExistsInEditor()) {
75+
updateExistingToc()
76+
} else {
77+
addTocAtCursorPosition()
78+
}
79+
}
80+
81+
/**
82+
* Generates MD TOC based on MD document passed as string.
83+
* @param markdownText MD document
84+
* @returns generatedTOC String containing generated TOC
85+
*/
86+
export function generate (markdownText) {
87+
const generatedToc = toc(markdownText, {slugify: caseSensitiveSlugify})
88+
return TOC_MARKER_START + EOL + EOL + generatedToc.content + EOL + EOL + TOC_MARKER_END
89+
}
90+
91+
function wrapTocWithEol (toc, editor) {
92+
const leftWrap = editor.getCursor().ch === 0 ? '' : EOL
93+
const rightWrap = editor.getLine(editor.getCursor().line).length === editor.getCursor().ch ? '' : EOL
94+
return leftWrap + toc + rightWrap
95+
}
96+
97+
export default {
98+
generate,
99+
generateInEditor
100+
}

browser/main/Detail/MarkdownNoteDetail.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { formatDate } from 'browser/lib/date-formatter'
2929
import { getTodoPercentageOfCompleted } from 'browser/lib/getTodoStatus'
3030
import striptags from 'striptags'
3131
import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote'
32+
import markdownToc from 'browser/lib/markdown-toc-generator'
3233

3334
class MarkdownNoteDetail extends React.Component {
3435
constructor (props) {
@@ -47,6 +48,7 @@ class MarkdownNoteDetail extends React.Component {
4748
this.dispatchTimer = null
4849

4950
this.toggleLockButton = this.handleToggleLockButton.bind(this)
51+
this.generateToc = () => this.handleGenerateToc()
5052
}
5153

5254
focus () {
@@ -59,6 +61,7 @@ class MarkdownNoteDetail extends React.Component {
5961
const reversedType = this.state.editorType === 'SPLIT' ? 'EDITOR_PREVIEW' : 'SPLIT'
6062
this.handleSwitchMode(reversedType)
6163
})
64+
ee.on('code:generate-toc', this.generateToc)
6265
}
6366

6467
componentWillReceiveProps (nextProps) {
@@ -75,6 +78,7 @@ class MarkdownNoteDetail extends React.Component {
7578

7679
componentWillUnmount () {
7780
ee.off('topbar:togglelockbutton', this.toggleLockButton)
81+
ee.off('code:generate-toc', this.generateToc)
7882
if (this.saveQueue != null) this.saveNow()
7983
}
8084

@@ -262,6 +266,11 @@ class MarkdownNoteDetail extends React.Component {
262266
}
263267
}
264268

269+
handleGenerateToc () {
270+
const editor = this.refs.content.refs.code.editor
271+
markdownToc.generateInEditor(editor)
272+
}
273+
265274
handleFocus (e) {
266275
this.focus()
267276
}

browser/main/Detail/SnippetNoteDetail.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import InfoPanelTrashed from './InfoPanelTrashed'
2929
import { formatDate } from 'browser/lib/date-formatter'
3030
import i18n from 'browser/lib/i18n'
3131
import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote'
32+
import markdownToc from 'browser/lib/markdown-toc-generator'
3233

3334
const electron = require('electron')
3435
const { remote } = electron
@@ -52,6 +53,7 @@ class SnippetNoteDetail extends React.Component {
5253
}
5354

5455
this.scrollToNextTabThreshold = 0.7
56+
this.generateToc = () => this.handleGenerateToc()
5557
}
5658

5759
componentDidMount () {
@@ -65,6 +67,7 @@ class SnippetNoteDetail extends React.Component {
6567
enableLeftArrow: allTabs.offsetLeft !== 0
6668
})
6769
}
70+
ee.on('code:generate-toc', this.generateToc)
6871
}
6972

7073
componentWillReceiveProps (nextProps) {
@@ -91,6 +94,16 @@ class SnippetNoteDetail extends React.Component {
9194

9295
componentWillUnmount () {
9396
if (this.saveQueue != null) this.saveNow()
97+
ee.off('code:generate-toc', this.generateToc)
98+
}
99+
100+
handleGenerateToc () {
101+
const { note, snippetIndex } = this.state
102+
const currentMode = note.snippets[snippetIndex].mode
103+
if (currentMode.includes('Markdown')) {
104+
const currentEditor = this.refs[`code-${snippetIndex}`].refs.code.editor
105+
markdownToc.generateInEditor(currentEditor)
106+
}
94107
}
95108

96109
handleChange (e) {
@@ -441,7 +454,7 @@ class SnippetNoteDetail extends React.Component {
441454
const isSuper = global.process.platform === 'darwin'
442455
? e.metaKey
443456
: e.ctrlKey
444-
if (isSuper && !e.shiftKey) {
457+
if (isSuper && !e.shiftKey && !e.altKey) {
445458
e.preventDefault()
446459
this.addSnippet()
447460
}

lib/main-menu.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ const file = {
145145
{
146146
type: 'separator'
147147
},
148+
{
149+
label: 'Generate/Update Markdown TOC',
150+
accelerator: 'Shift+Ctrl+T',
151+
click () {
152+
mainWindow.webContents.send('code:generate-toc')
153+
}
154+
},
155+
{
156+
type: 'separator'
157+
},
148158
{
149159
label: 'Print',
150160
accelerator: 'CommandOrControl+P',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"markdown-it-named-headers": "^0.0.4",
8181
"markdown-it-plantuml": "^1.1.0",
8282
"markdown-it-smartarrows": "^1.0.1",
83+
"markdown-toc": "^1.2.0",
8384
"mdurl": "^1.0.1",
8485
"mermaid": "^8.0.0-rc.8",
8586
"moment": "^2.10.3",

tests/helpers/setup-browser-env.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
import browserEnv from 'browser-env'
2-
browserEnv(['window', 'document'])
2+
browserEnv(['window', 'document', 'navigator'])
3+
4+
// for CodeMirror mockup
5+
document.body.createTextRange = function () {
6+
return {
7+
setEnd: function () {},
8+
setStart: function () {},
9+
getBoundingClientRect: function () {
10+
return {right: 0}
11+
},
12+
getClientRects: function () {
13+
return {
14+
length: 0,
15+
left: 0,
16+
right: 0
17+
}
18+
}
19+
}
20+
}
321

422
window.localStorage = {
523
// polyfill

0 commit comments

Comments
 (0)