cm-md-editor 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.html +65 -17
  2. package/package.json +1 -1
  3. package/src/MdEditor.js +266 -33
package/index.html CHANGED
@@ -3,13 +3,16 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>extreme-minimal-md-editor</title>
6
+ <title>cm-md-editor</title>
7
7
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
8
8
  integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
9
9
  <style>
10
+ body {
11
+ padding: 1rem; margin-top: 0rem;
12
+ }
10
13
  #editor {
11
14
  width: 100%;
12
- height: calc(100vh - 6rem);
15
+ height: calc(100vh - 5rem);
13
16
  padding: 0.7rem;
14
17
  font-family: monospace;
15
18
  tab-size: 4;
@@ -17,27 +20,72 @@
17
20
  }
18
21
  </style>
19
22
  </head>
20
- <body class="p-2">
23
+ <body>
21
24
  <div class="container-fluid">
22
- <label for="editor">cm-md-editor</label>
25
+ <label for="editor" style="display: none">cm-md-editor</label>
23
26
  <textarea id="editor">
24
- # cm-md-editor
27
+ ---
28
+ title: cm-md-editor
29
+ description: Some example text for the editor
30
+ ---
31
+
32
+ # Heading 1
33
+
34
+ ## Heading 2
35
+
36
+ ### Heading 3
37
+
38
+ This is a minimal **markdown editor** with _italic_ and **_bold italic_** text. You can also use ~~strikethrough~~ for deleted text.
39
+
40
+ <!-- This is a comment -->
41
+
42
+ <span>Some HTML</span>
43
+
44
+ Inline `code spans` are highlighted and protect their content: `**not bold**`.
45
+
46
+ > Blockquotes are highlighted with a green prefix.
47
+
48
+ ### Links and images
49
+
50
+ Visit [the demo page](https://shaack.com/projekte/cm-md-editor/) or use a [reference link][ref].
51
+
52
+ ![An example image](https://example.com/image.png)
53
+
54
+ [ref]: https://shaack.com
55
+
56
+ ### Lists
57
+
58
+ - Unordered list item
59
+ - Another item
60
+ - Nested item
61
+ - Deeper nesting
62
+
63
+ 1. Ordered list item
64
+ 2. Second item
65
+
66
+ ### Task lists
67
+
68
+ - [ ] Unchecked task
69
+ - [x] Completed task
70
+
71
+ ### Code block
72
+
73
+ ```javascript
74
+ const editor = new MdEditor(element)
75
+ console.log("syntax highlighted")
76
+ ```
77
+
78
+ ### Horizontal rules
79
+
80
+ ---
25
81
 
26
- This is a minimal **markdown editor** which is used in chessmail.de.
82
+ ### Escape sequences
27
83
 
28
- ## Key features
84
+ Literal stars: \*not bold\* and backslash: \\
29
85
 
30
- - Vanilla JavaScript module, without dependencies
31
- - Supports outlines with tab and shift-tab to indent and outdent
32
- - Supports the **bold** syntax with command-b/ctrl-b
33
- - Supports the _italic_ syntax with command-i/ctrl-i
34
- - Supports undo and redo with command-z/ctrl-z and command-shift-z/ctrl-shift-z
35
- - It is…
36
- - lightweight
37
- - easy to use
38
- - fast
86
+ ### HTML tags
39
87
 
40
- ☝️ [Check out the Demo page](https://shaack.com/projekte/cm-md-editor/)</textarea>
88
+ This has <mark>inline HTML</mark> and a <br> tag.</textarea>
41
89
  </div>
42
90
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
43
91
  integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cm-md-editor",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "a simple markdown editor",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/MdEditor.js CHANGED
@@ -4,8 +4,26 @@
4
4
  */
5
5
  export class MdEditor {
6
6
 
7
- constructor(element) {
7
+ constructor(element, props) {
8
8
  this.element = element
9
+ this.props = {
10
+ syntaxHighlight: 1, // opacity of the highlighting, set 0 to disable
11
+ colorHeading: "100,160,255",
12
+ colorCode: "130,170,200",
13
+ colorComment: "128,128,128",
14
+ colorLink: "100,180,220",
15
+ colorBlockquote: "100,200,150",
16
+ colorList: "100,200,150",
17
+ colorStrikethrough: "255,100,100",
18
+ colorBold: "255,180,80",
19
+ colorItalic: "180,130,255",
20
+ colorHtmlTag: "200,120,120",
21
+ colorHorizontalRule: "128,128,200",
22
+ colorEscape: "128,128,128",
23
+ colorFrontMatter: "128,128,200",
24
+ toolbarButtons: ["h1", "h2", "h3", "bold", "italic", "ul", "ol", "link", "image"],
25
+ ...props
26
+ }
9
27
  this.element.addEventListener('keydown', (e) => this.handleKeyDown(e))
10
28
  this.createToolbar()
11
29
  this.createHighlightBackdrop()
@@ -19,21 +37,24 @@ export class MdEditor {
19
37
  toolbar.style.cssText = 'display:flex;gap:1px;padding:2px;flex-wrap:wrap;background:rgba(128,128,128,0.15);border:1px solid rgba(128,128,128,0.3);border-bottom:none;border-radius:4px 4px 0 0;box-sizing:border-box;width:100%;'
20
38
  wrapper.insertBefore(toolbar, this.element)
21
39
  this.element.style.borderRadius = '0 0 4px 4px'
22
- const buttons = [
23
- {title: 'Heading 1', icon: '<text x="12" y="17.5" font-size="16" font-family="system-ui,sans-serif" font-weight="700" text-anchor="middle">H1</text>', action: () => this.toggleHeading(1)},
24
- {title: 'Heading 2', icon: '<text x="12" y="17.5" font-size="16" font-family="system-ui,sans-serif" font-weight="700" text-anchor="middle">H2</text>', action: () => this.toggleHeading(2)},
25
- {title: 'Heading 3', icon: '<text x="12" y="17.5" font-size="16" font-family="system-ui,sans-serif" font-weight="700" text-anchor="middle">H3</text>', action: () => this.toggleHeading(3)},
26
- {title: 'Bold', icon: '<text x="12" y="18" font-size="18" font-family="system-ui,sans-serif" font-weight="800" text-anchor="middle">B</text>', action: () => this.toggleBold()},
27
- {title: 'Italic', icon: '<text x="12" y="18" font-size="18" font-family="system-ui,sans-serif" font-weight="600" font-style="italic" text-anchor="middle">I</text>', action: () => this.toggleItalic()},
28
- {title: 'Unordered List', icon: '<circle cx="5" cy="7" r="1.8"/><circle cx="5" cy="17" r="1.8"/><rect x="9.5" y="5.5" width="11" height="3" rx="1"/><rect x="9.5" y="15.5" width="11" height="3" rx="1"/>', action: () => this.insertUnorderedList()},
29
- {title: 'Ordered List', icon: '<text x="3" y="9.5" font-size="8.5" font-family="system-ui,sans-serif" font-weight="700">1</text><text x="3" y="19.5" font-size="8.5" font-family="system-ui,sans-serif" font-weight="700">2</text><rect x="9.5" y="5.5" width="11" height="3" rx="1"/><rect x="9.5" y="15.5" width="11" height="3" rx="1"/>', action: () => this.insertOrderedList()},
40
+ const allButtons = [
41
+ {name: 'h1', title: 'Heading 1', icon: '<path d="M7.648 13V3H6.3v4.234H1.348V3H0v10h1.348V8.421H6.3V13zM14 13V3h-1.333l-2.381 1.766V6.12L12.6 4.443h.066V13z"/>', action: () => this.toggleHeading(1)},
42
+ {name: 'h2', title: 'Heading 2', icon: '<path d="M7.495 13V3.201H6.174v4.15H1.32V3.2H0V13h1.32V8.513h4.854V13zm3.174-7.071v-.05c0-.934.66-1.752 1.801-1.752 1.005 0 1.76.639 1.76 1.651 0 .898-.582 1.58-1.12 2.19l-3.69 4.2V13h6.331v-1.149h-4.458v-.079L13.9 8.786c.919-1.048 1.666-1.874 1.666-3.101C15.565 4.149 14.35 3 12.499 3 10.46 3 9.384 4.393 9.384 5.879v.05z"/>', action: () => this.toggleHeading(2)},
43
+ {name: 'h3', title: 'Heading 3', icon: '<path d="M11.07 8.4h1.049c1.174 0 1.99.69 2.004 1.724s-.802 1.786-2.068 1.779c-1.11-.007-1.905-.605-1.99-1.357h-1.21C8.926 11.91 10.116 13 12.028 13c1.99 0 3.439-1.188 3.404-2.87-.028-1.553-1.287-2.221-2.096-2.313v-.07c.724-.127 1.814-.935 1.772-2.293-.035-1.392-1.21-2.468-3.038-2.454-1.927.007-2.94 1.196-2.981 2.426h1.23c.064-.71.732-1.336 1.744-1.336 1.027 0 1.744.64 1.744 1.568.007.95-.738 1.639-1.744 1.639h-.991V8.4ZM7.495 13V3.201H6.174v4.15H1.32V3.2H0V13h1.32V8.513h4.854V13z"/>', action: () => this.toggleHeading(3)},
44
+ {name: 'bold', title: 'Bold', icon: '<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 0 0 1.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"/>', action: () => this.toggleBold()},
45
+ {name: 'italic', title: 'Italic', icon: '<path d="M7.991 11.674 9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"/>', action: () => this.toggleItalic()},
46
+ {name: 'ul', title: 'Unordered List', icon: '<path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m-3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>', action: () => this.insertUnorderedList()},
47
+ {name: 'ol', title: 'Ordered List', icon: '<path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5"/><path d="M1.713 11.865v-.474H2c.217 0 .363-.137.363-.317 0-.185-.158-.31-.361-.31-.223 0-.367.152-.373.31h-.59c.016-.467.373-.787.986-.787.588-.002.954.291.957.703a.595.595 0 0 1-.492.594v.033a.615.615 0 0 1 .569.631c.003.533-.502.8-1.051.8-.656 0-1-.37-1.008-.794h.582c.008.178.186.306.422.309.254 0 .424-.145.422-.35-.002-.195-.155-.348-.414-.348h-.3zm-.004-4.699h-.604v-.035c0-.408.295-.844.958-.844.583 0 .96.326.96.756 0 .389-.257.617-.476.848l-.537.572v.03h1.054V9H1.143v-.395l.957-.99c.138-.142.293-.304.293-.508 0-.18-.147-.32-.342-.32a.33.33 0 0 0-.342.338zM2.564 5h-.635V2.924h-.031l-.598.42v-.567l.629-.443h.635z"/>', action: () => this.insertOrderedList()},
48
+ {name: 'link', title: 'Insert Link', icon: '<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1 1 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4 4 0 0 1-.128-1.287z"/><path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"/>', action: () => this.insertLink()},
49
+ {name: 'image', title: 'Insert Image', icon: '<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0"/><path d="M1.5 2A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h13a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2zm13 1a.5.5 0 0 1 .5.5v6l-3.775-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12v.54L1 12.5v-9a.5.5 0 0 1 .5-.5z"/>', action: () => this.insertImage()},
30
50
  ]
51
+ const buttons = allButtons.filter(btn => this.props.toolbarButtons.includes(btn.name))
31
52
  buttons.forEach(btn => {
32
53
  const button = document.createElement('button')
33
54
  button.type = 'button'
34
55
  button.title = btn.title
35
56
  button.style.cssText = 'background:none;border:none;border-radius:3px;cursor:pointer;padding:4px 6px;display:flex;align-items:center;justify-content:center;color:inherit;opacity:0.6;transition:opacity 0.15s,background 0.15s;'
36
- button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">${btn.icon}</svg>`
57
+ button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16" fill="currentColor">${btn.icon}</svg>`
37
58
  button.addEventListener('mouseenter', () => { button.style.opacity = '1'; button.style.background = 'rgba(128,128,128,0.2)' })
38
59
  button.addEventListener('mouseleave', () => { button.style.opacity = '0.6'; button.style.background = 'none' })
39
60
  button.addEventListener('mousedown', (e) => {
@@ -45,9 +66,32 @@ export class MdEditor {
45
66
  })
46
67
  toolbar.appendChild(button)
47
68
  })
69
+
70
+ // Spacer to push wrap toggle to the right
71
+ const spacer = document.createElement('div')
72
+ spacer.style.cssText = 'flex:1;'
73
+ toolbar.appendChild(spacer)
74
+
75
+ // Wrap toggle button
76
+ this.wrapEnabled = true
77
+ this.wrapButton = document.createElement('button')
78
+ this.wrapButton.type = 'button'
79
+ this.wrapButton.title = 'Toggle word wrap'
80
+ this.wrapButton.style.cssText = 'background:none;border:none;border-radius:3px;cursor:pointer;padding:4px 6px;display:flex;align-items:center;justify-content:center;color:inherit;opacity:0.6;transition:opacity 0.15s,background 0.15s;'
81
+ this.wrapButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5m0 4a.5.5 0 0 1 .5-.5h9a2.5 2.5 0 0 1 0 5h-1.293l.647.646a.5.5 0 0 1-.708.708l-1.5-1.5a.5.5 0 0 1 0-.708l1.5-1.5a.5.5 0 0 1 .708.708l-.647.646H11.5a1.5 1.5 0 0 0 0-3h-9a.5.5 0 0 1-.5-.5m0 4a.5.5 0 0 1 .5-.5H7a.5.5 0 0 1 0 1H2.5a.5.5 0 0 1-.5-.5"/></svg>`
82
+ this.wrapButton.addEventListener('mouseenter', () => { this.wrapButton.style.opacity = '1'; this.wrapButton.style.background = 'rgba(128,128,128,0.2)' })
83
+ this.wrapButton.addEventListener('mouseleave', () => { this.wrapButton.style.opacity = this.wrapEnabled ? '0.9' : '0.4'; this.wrapButton.style.background = 'none' })
84
+ this.wrapButton.addEventListener('mousedown', (e) => e.preventDefault())
85
+ this.wrapButton.addEventListener('click', (e) => {
86
+ e.preventDefault()
87
+ this.toggleWrapMode()
88
+ })
89
+ this.wrapButton.style.opacity = '0.9'
90
+ toolbar.appendChild(this.wrapButton)
48
91
  }
49
92
 
50
93
  createHighlightBackdrop() {
94
+ if (!this.props.syntaxHighlight) return
51
95
  const container = this.element.parentNode
52
96
  container.style.position = 'relative'
53
97
  this.backdrop = document.createElement('div')
@@ -57,7 +101,7 @@ export class MdEditor {
57
101
 
58
102
  // Copy textarea computed styles to backdrop
59
103
  const cs = window.getComputedStyle(this.element)
60
- this.backdrop.style.cssText = `position:absolute;overflow:hidden;pointer-events:none;z-index:1;`
104
+ this.backdrop.style.cssText = `position:absolute;overflow:hidden;pointer-events:none;z-index:1;opacity:${this.props.syntaxHighlight};`
61
105
  this.highlightLayer.style.cssText = `white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;color:transparent;`
62
106
 
63
107
  const syncStyles = () => {
@@ -78,13 +122,15 @@ export class MdEditor {
78
122
  syncStyles()
79
123
 
80
124
  // Make textarea background transparent so backdrop shows through
125
+ this.element.style.overscrollBehavior = 'none'
81
126
  this.element.style.background = 'transparent'
82
127
  this.element.style.position = 'relative'
83
128
  this.element.style.caretColor = cs.color
84
129
 
85
130
  // Sync scroll
86
131
  this.element.addEventListener('scroll', () => {
87
- this.highlightLayer.style.transform = `translate(0, -${this.element.scrollTop}px)`
132
+ this.backdrop.scrollTop = this.element.scrollTop
133
+ this.backdrop.scrollLeft = this.element.scrollLeft
88
134
  })
89
135
 
90
136
  // Update on input
@@ -93,42 +139,201 @@ export class MdEditor {
93
139
  this.updateHighlight()
94
140
  }
95
141
 
142
+ colorSpan(colorProp, content) {
143
+ return '<span style="color:rgba(' + this.props[colorProp] + ',1)">' + content + '</span>'
144
+ }
145
+
96
146
  updateHighlight() {
97
147
  const text = this.element.value
98
148
  const lines = text.split('\n')
99
149
  let html = ''
150
+ let inCodeBlock = false
151
+ let inHtmlComment = false
152
+ let inFrontMatter = false
153
+ // YAML front matter must start at the very first line
154
+ if (lines.length > 0 && lines[0].trim() === '---') {
155
+ inFrontMatter = true
156
+ }
157
+
100
158
  for (let i = 0; i < lines.length; i++) {
101
159
  if (i > 0) html += '\n'
102
- let line = lines[i]
103
- let result = this.escapeHtml(line)
160
+ const line = lines[i]
161
+
162
+ // YAML front matter
163
+ if (inFrontMatter) {
164
+ html += this.colorSpan('colorFrontMatter', this.escapeHtml(line))
165
+ if (i > 0 && line.trim() === '---') {
166
+ inFrontMatter = false
167
+ }
168
+ continue
169
+ }
170
+
171
+ // Fenced code block delimiter
172
+ if (/^`{3,}/.test(line)) {
173
+ inCodeBlock = !inCodeBlock
174
+ html += this.colorSpan('colorCode', this.escapeHtml(line))
175
+ continue
176
+ }
177
+ if (inCodeBlock) {
178
+ html += this.colorSpan('colorCode', this.escapeHtml(line))
179
+ continue
180
+ }
181
+
182
+ // HTML comment handling (can span multiple lines)
183
+ if (inHtmlComment) {
184
+ const endIdx = line.indexOf('-->')
185
+ if (endIdx !== -1) {
186
+ inHtmlComment = false
187
+ html += this.colorSpan('colorComment', this.escapeHtml(line.substring(0, endIdx + 3)))
188
+ html += this.highlightInline(line.substring(endIdx + 3))
189
+ } else {
190
+ html += this.colorSpan('colorComment', this.escapeHtml(line))
191
+ }
192
+ continue
193
+ }
194
+ if (line.trimStart().startsWith('<!--')) {
195
+ const endIdx = line.indexOf('-->', line.indexOf('<!--') + 4)
196
+ if (endIdx !== -1) {
197
+ // Single-line comment
198
+ html += this.colorSpan('colorComment', this.escapeHtml(line))
199
+ } else {
200
+ // Multi-line comment starts
201
+ inHtmlComment = true
202
+ html += this.colorSpan('colorComment', this.escapeHtml(line))
203
+ }
204
+ continue
205
+ }
104
206
 
105
- // Headings: emphasize entire line
207
+ // Horizontal rule (3+ of same -, *, or _ with optional spaces)
208
+ if (/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(line)) {
209
+ html += this.colorSpan('colorHorizontalRule', this.escapeHtml(line))
210
+ continue
211
+ }
212
+
213
+ // Headings
106
214
  const headingMatch = line.match(/^(#{1,6}) /)
107
215
  if (headingMatch) {
108
- const hashes = this.escapeHtml(headingMatch[1])
109
- const rest = this.escapeHtml(line.substring(headingMatch[0].length))
110
- const opacity = Math.max(0.3, 0.8 - (headingMatch[1].length - 1) * 0.1)
111
- result = `<span style="color:rgba(100,160,255,${opacity})">${hashes} </span><span style="color:rgba(100,160,255,${opacity})">${rest}</span>`
112
- } else {
113
- // Bold **text**
114
- result = result.replace(/(\*\*)(.*?)(\*\*)/g,
115
- '<span style="color:rgba(255,180,80,0.5)">$1</span><span style="color:rgba(255,180,80,0.8)">$2</span><span style="color:rgba(255,180,80,0.5)">$3</span>')
116
- // Italic _text_
117
- result = result.replace(/((?:^|[^\\]))(\_)(.*?[^\\])(\_)/g,
118
- '$1<span style="color:rgba(180,130,255,0.5)">$2</span><span style="color:rgba(180,130,255,0.8)">$3</span><span style="color:rgba(180,130,255,0.5)">$4</span>')
119
- // Unordered list markers
120
- result = result.replace(/^(\t*)(- )/, (_, tabs, marker) =>
121
- this.escapeHtml(tabs) + '<span style="color:rgba(100,200,150,0.7)">' + this.escapeHtml(marker) + '</span>')
122
- // Ordered list markers
123
- result = result.replace(/^(\t*)(\d+\. )/, (_, tabs, marker) =>
124
- this.escapeHtml(tabs) + '<span style="color:rgba(100,200,150,0.7)">' + this.escapeHtml(marker) + '</span>')
216
+ const opacity = Math.max(0.3, 1 - (headingMatch[1].length - 1) * 0.1)
217
+ html += '<span style="color:rgba(' + this.props.colorHeading + ',' + opacity + ')">' + this.escapeHtml(line) + '</span>'
218
+ continue
125
219
  }
126
- html += result
220
+
221
+ // Reference link definition [ref]: url
222
+ const refMatch = line.match(/^(\s{0,3}\[)([^\]]+)(\]:\s+)(.+)$/)
223
+ if (refMatch) {
224
+ html += this.colorSpan('colorLink', this.escapeHtml(refMatch[1]))
225
+ + this.colorSpan('colorLink', this.escapeHtml(refMatch[2]))
226
+ + this.colorSpan('colorLink', this.escapeHtml(refMatch[3]))
227
+ + this.colorSpan('colorLink', this.escapeHtml(refMatch[4]))
228
+ continue
229
+ }
230
+
231
+ // Blockquote prefix
232
+ let prefix = ''
233
+ let rest = line
234
+ const bqMatch = line.match(/^(\s*>+\s?)/)
235
+ if (bqMatch) {
236
+ prefix = this.colorSpan('colorBlockquote', this.escapeHtml(bqMatch[0]))
237
+ rest = line.substring(bqMatch[0].length)
238
+ }
239
+
240
+ html += prefix + this.highlightInline(rest)
127
241
  }
128
242
  // Trailing newline so the backdrop height matches the textarea
129
243
  this.highlightLayer.innerHTML = html + '\n'
130
244
  }
131
245
 
246
+ highlightInline(line) {
247
+ // Split by inline code to protect code content from other highlighting
248
+ const segments = []
249
+ let lastIndex = 0
250
+ const codeRegex = /`([^`]+)`/g
251
+ let match
252
+
253
+ while ((match = codeRegex.exec(line)) !== null) {
254
+ if (match.index > lastIndex) {
255
+ segments.push({type: 'text', content: line.substring(lastIndex, match.index)})
256
+ }
257
+ segments.push({type: 'code', content: match[0]})
258
+ lastIndex = codeRegex.lastIndex
259
+ }
260
+ if (lastIndex < line.length) {
261
+ segments.push({type: 'text', content: line.substring(lastIndex)})
262
+ }
263
+ if (segments.length === 0) {
264
+ segments.push({type: 'text', content: ''})
265
+ }
266
+
267
+ let result = ''
268
+ for (const seg of segments) {
269
+ if (seg.type === 'code') {
270
+ result += this.colorSpan('colorCode', this.escapeHtml(seg.content))
271
+ } else {
272
+ result += this.highlightTextSegment(this.escapeHtml(seg.content))
273
+ }
274
+ }
275
+ return result
276
+ }
277
+
278
+ highlightTextSegment(escaped) {
279
+ let result = escaped
280
+ const c = (prop) => this.props[prop]
281
+
282
+ // Escape sequences: dim the backslash before markdown punctuation
283
+ result = result.replace(/\\([\\`*_{}[\]()#+\-.!~|])/g,
284
+ '<span style="color:rgba(' + c('colorEscape') + ',1)">\\</span>$1')
285
+
286
+ // Unordered list markers with optional task list checkbox
287
+ result = result.replace(/^(\t*)(- )(\[[ xX]\] )?/, (_, tabs, marker, task) => {
288
+ let r = tabs + this.colorSpan('colorList', marker)
289
+ if (task) {
290
+ r += this.colorSpan('colorList', task)
291
+ }
292
+ return r
293
+ })
294
+
295
+ // Ordered list markers
296
+ result = result.replace(/^(\t*)(\d+\. )/, (_, tabs, marker) =>
297
+ tabs + this.colorSpan('colorList', marker))
298
+
299
+ // Images ![alt](url) and Links [text](url)
300
+ result = result.replace(/(!?\[)(.*?)(\]\()(.+?)(\))/g, (_, p1, p2, p3, p4, p5) =>
301
+ this.colorSpan('colorLink', p1) + this.colorSpan('colorLink', p2) + this.colorSpan('colorLink', p3) + this.colorSpan('colorLink', p4) + this.colorSpan('colorLink', p5))
302
+
303
+ // Reference links [text][ref]
304
+ result = result.replace(/(\[)(.*?)(\]\[)(.*?)(\])/g, (_, p1, p2, p3, p4, p5) =>
305
+ this.colorSpan('colorLink', p1) + this.colorSpan('colorLink', p2) + this.colorSpan('colorLink', p3) + this.colorSpan('colorLink', p4) + this.colorSpan('colorLink', p5))
306
+
307
+ // Strikethrough ~~text~~
308
+ result = result.replace(/(~~)(.*?)(~~)/g, (_, p1, p2, p3) =>
309
+ this.colorSpan('colorStrikethrough', p1) + this.colorSpan('colorStrikethrough', p2) + this.colorSpan('colorStrikethrough', p3))
310
+
311
+ // Bold **text**
312
+ result = result.replace(/(\*\*)(.*?)(\*\*)/g, (_, p1, p2, p3) =>
313
+ this.colorSpan('colorBold', p1) + this.colorSpan('colorBold', p2) + this.colorSpan('colorBold', p3))
314
+
315
+ // Italic _text_
316
+ result = result.replace(/((?:^|[^\\]))(\_)(.*?[^\\])(\_)/g, (_, pre, p1, p2, p3) =>
317
+ pre + this.colorSpan('colorItalic', p1) + this.colorSpan('colorItalic', p2) + this.colorSpan('colorItalic', p3))
318
+
319
+ // HTML tags
320
+ result = result.replace(/(&lt;)(\/?[a-zA-Z]\w*)(.*?)(&gt;)/g, (_, p1, p2, p3, p4) =>
321
+ this.colorSpan('colorHtmlTag', p1 + p2 + p3 + p4))
322
+
323
+ return result
324
+ }
325
+
326
+ toggleWrapMode() {
327
+ this.wrapEnabled = !this.wrapEnabled
328
+ const wrap = this.wrapEnabled
329
+ this.element.style.whiteSpace = wrap ? 'pre-wrap' : 'pre'
330
+ this.element.style.overflowX = wrap ? 'hidden' : 'auto'
331
+ this.highlightLayer.style.whiteSpace = wrap ? 'pre-wrap' : 'pre'
332
+ this.highlightLayer.style.overflowWrap = wrap ? 'break-word' : 'normal'
333
+ this.wrapButton.style.opacity = wrap ? '0.9' : '0.4'
334
+ this.updateHighlight()
335
+ }
336
+
132
337
  escapeHtml(str) {
133
338
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
134
339
  }
@@ -217,6 +422,34 @@ export class MdEditor {
217
422
  }
218
423
  }
219
424
 
425
+ insertLink() {
426
+ const start = this.element.selectionStart
427
+ const end = this.element.selectionEnd
428
+ const selected = this.element.value.substring(start, end)
429
+ const url = prompt('Enter URL:')
430
+ if (url === null) return
431
+ const linkText = selected || 'link'
432
+ this.element.focus()
433
+ this.selectLineRange(start, end)
434
+ this.insertTextAtCursor('[' + linkText + '](' + url + ')')
435
+ this.element.selectionStart = start + 1
436
+ this.element.selectionEnd = start + 1 + linkText.length
437
+ }
438
+
439
+ insertImage() {
440
+ const start = this.element.selectionStart
441
+ const end = this.element.selectionEnd
442
+ const selected = this.element.value.substring(start, end)
443
+ const url = prompt('Enter image URL:')
444
+ if (url === null) return
445
+ const altText = selected || 'image'
446
+ this.element.focus()
447
+ this.selectLineRange(start, end)
448
+ this.insertTextAtCursor('![' + altText + '](' + url + ')')
449
+ this.element.selectionStart = start + 2
450
+ this.element.selectionEnd = start + 2 + altText.length
451
+ }
452
+
220
453
  insertTextAtCursor(text) {
221
454
  // execCommand is deprecated, but without alternative to insert text and preserve the correct undo/redo stack
222
455
  document.execCommand("insertText", false, text)