cm-md-editor 2.0.0 → 2.1.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 +49 -13
  2. package/package.json +1 -1
  3. package/src/MdEditor.js +159 -21
package/index.html CHANGED
@@ -21,23 +21,59 @@
21
21
  <div class="container-fluid">
22
22
  <label for="editor">cm-md-editor</label>
23
23
  <textarea id="editor">
24
- # cm-md-editor
24
+ # Heading 1
25
25
 
26
- This is a minimal **markdown editor** which is used in chessmail.de.
26
+ ## Heading 2
27
27
 
28
- ## Key features
28
+ ### Heading 3
29
29
 
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
30
+ This is a minimal **markdown editor** with _italic_ and **_bold italic_** text. You can also use ~~strikethrough~~ for deleted text.
39
31
 
40
- ☝️ [Check out the Demo page](https://shaack.com/projekte/cm-md-editor/)</textarea>
32
+ Inline `code spans` are highlighted and protect their content: `**not bold**`.
33
+
34
+ > Blockquotes are highlighted with a green prefix.
35
+
36
+ ### Links and images
37
+
38
+ Visit [the demo page](https://shaack.com/projekte/cm-md-editor/) or use a [reference link][ref].
39
+
40
+ ![An example image](https://example.com/image.png)
41
+
42
+ [ref]: https://shaack.com
43
+
44
+ ### Lists
45
+
46
+ - Unordered list item
47
+ - Another item
48
+ - Nested item
49
+ - Deeper nesting
50
+
51
+ 1. Ordered list item
52
+ 2. Second item
53
+
54
+ ### Task lists
55
+
56
+ - [ ] Unchecked task
57
+ - [x] Completed task
58
+
59
+ ### Code block
60
+
61
+ ```javascript
62
+ const editor = new MdEditor(element)
63
+ console.log("syntax highlighted")
64
+ ```
65
+
66
+ ### Horizontal rules
67
+
68
+ ---
69
+
70
+ ### Escape sequences
71
+
72
+ Literal stars: \*not bold\* and backslash: \\
73
+
74
+ ### HTML tags
75
+
76
+ This has <mark>inline HTML</mark> and a <br> tag.</textarea>
41
77
  </div>
42
78
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
43
79
  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.1.0",
4
4
  "description": "a simple markdown editor",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/MdEditor.js CHANGED
@@ -45,6 +45,28 @@ export class MdEditor {
45
45
  })
46
46
  toolbar.appendChild(button)
47
47
  })
48
+
49
+ // Spacer to push wrap toggle to the right
50
+ const spacer = document.createElement('div')
51
+ spacer.style.cssText = 'flex:1;'
52
+ toolbar.appendChild(spacer)
53
+
54
+ // Wrap toggle button
55
+ this.wrapEnabled = true
56
+ this.wrapButton = document.createElement('button')
57
+ this.wrapButton.type = 'button'
58
+ this.wrapButton.title = 'Toggle word wrap'
59
+ 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;'
60
+ this.wrapButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h16v2H4zm0 10h6v2H4zm0-5h16c0 0 0 0 0 0v0c0 2.2-1.8 4-4 4h-2l2-2h-1l-3 3 3 3h1l-2-2h2c3.3 0 6-2.7 6-6H4z"/></svg>`
61
+ this.wrapButton.addEventListener('mouseenter', () => { this.wrapButton.style.opacity = '1'; this.wrapButton.style.background = 'rgba(128,128,128,0.2)' })
62
+ this.wrapButton.addEventListener('mouseleave', () => { this.wrapButton.style.opacity = this.wrapEnabled ? '0.9' : '0.4'; this.wrapButton.style.background = 'none' })
63
+ this.wrapButton.addEventListener('mousedown', (e) => e.preventDefault())
64
+ this.wrapButton.addEventListener('click', (e) => {
65
+ e.preventDefault()
66
+ this.toggleWrapMode()
67
+ })
68
+ this.wrapButton.style.opacity = '0.9'
69
+ toolbar.appendChild(this.wrapButton)
48
70
  }
49
71
 
50
72
  createHighlightBackdrop() {
@@ -78,13 +100,15 @@ export class MdEditor {
78
100
  syncStyles()
79
101
 
80
102
  // Make textarea background transparent so backdrop shows through
103
+ this.element.style.overscrollBehavior = 'none'
81
104
  this.element.style.background = 'transparent'
82
105
  this.element.style.position = 'relative'
83
106
  this.element.style.caretColor = cs.color
84
107
 
85
108
  // Sync scroll
86
109
  this.element.addEventListener('scroll', () => {
87
- this.highlightLayer.style.transform = `translate(0, -${this.element.scrollTop}px)`
110
+ this.backdrop.scrollTop = this.element.scrollTop
111
+ this.backdrop.scrollLeft = this.element.scrollLeft
88
112
  })
89
113
 
90
114
  // Update on input
@@ -97,38 +121,152 @@ export class MdEditor {
97
121
  const text = this.element.value
98
122
  const lines = text.split('\n')
99
123
  let html = ''
124
+ let inCodeBlock = false
125
+
100
126
  for (let i = 0; i < lines.length; i++) {
101
127
  if (i > 0) html += '\n'
102
- let line = lines[i]
103
- let result = this.escapeHtml(line)
128
+ const line = lines[i]
104
129
 
105
- // Headings: emphasize entire line
130
+ // Fenced code block delimiter
131
+ if (/^`{3,}/.test(line)) {
132
+ inCodeBlock = !inCodeBlock
133
+ html += '<span style="color:rgba(130,170,200,0.7)">' + this.escapeHtml(line) + '</span>'
134
+ continue
135
+ }
136
+ if (inCodeBlock) {
137
+ html += '<span style="color:rgba(130,170,200,0.5)">' + this.escapeHtml(line) + '</span>'
138
+ continue
139
+ }
140
+
141
+ // Horizontal rule (3+ of same -, *, or _ with optional spaces)
142
+ if (/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(line)) {
143
+ html += '<span style="color:rgba(128,128,128,0.6)">' + this.escapeHtml(line) + '</span>'
144
+ continue
145
+ }
146
+
147
+ // Headings
106
148
  const headingMatch = line.match(/^(#{1,6}) /)
107
149
  if (headingMatch) {
108
- const hashes = this.escapeHtml(headingMatch[1])
109
- const rest = this.escapeHtml(line.substring(headingMatch[0].length))
110
150
  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>')
151
+ html += '<span style="color:rgba(100,160,255,' + opacity + ')">' + this.escapeHtml(line) + '</span>'
152
+ continue
125
153
  }
126
- html += result
154
+
155
+ // Reference link definition [ref]: url
156
+ const refMatch = line.match(/^(\s{0,3}\[)([^\]]+)(\]:\s+)(.+)$/)
157
+ if (refMatch) {
158
+ html += '<span style="color:rgba(100,180,220,0.5)">' + this.escapeHtml(refMatch[1]) + '</span>'
159
+ + '<span style="color:rgba(100,180,220,0.8)">' + this.escapeHtml(refMatch[2]) + '</span>'
160
+ + '<span style="color:rgba(100,180,220,0.5)">' + this.escapeHtml(refMatch[3]) + '</span>'
161
+ + '<span style="color:rgba(100,180,220,0.6)">' + this.escapeHtml(refMatch[4]) + '</span>'
162
+ continue
163
+ }
164
+
165
+ // Blockquote prefix
166
+ let prefix = ''
167
+ let rest = line
168
+ const bqMatch = line.match(/^(\s*>+\s?)/)
169
+ if (bqMatch) {
170
+ prefix = '<span style="color:rgba(128,180,128,0.7)">' + this.escapeHtml(bqMatch[0]) + '</span>'
171
+ rest = line.substring(bqMatch[0].length)
172
+ }
173
+
174
+ html += prefix + this.highlightInline(rest)
127
175
  }
128
176
  // Trailing newline so the backdrop height matches the textarea
129
177
  this.highlightLayer.innerHTML = html + '\n'
130
178
  }
131
179
 
180
+ highlightInline(line) {
181
+ // Split by inline code to protect code content from other highlighting
182
+ const segments = []
183
+ let lastIndex = 0
184
+ const codeRegex = /`([^`]+)`/g
185
+ let match
186
+
187
+ while ((match = codeRegex.exec(line)) !== null) {
188
+ if (match.index > lastIndex) {
189
+ segments.push({type: 'text', content: line.substring(lastIndex, match.index)})
190
+ }
191
+ segments.push({type: 'code', content: match[0]})
192
+ lastIndex = codeRegex.lastIndex
193
+ }
194
+ if (lastIndex < line.length) {
195
+ segments.push({type: 'text', content: line.substring(lastIndex)})
196
+ }
197
+ if (segments.length === 0) {
198
+ segments.push({type: 'text', content: ''})
199
+ }
200
+
201
+ let result = ''
202
+ for (const seg of segments) {
203
+ if (seg.type === 'code') {
204
+ result += '<span style="color:rgba(130,170,200,0.7)">' + this.escapeHtml(seg.content) + '</span>'
205
+ } else {
206
+ result += this.highlightTextSegment(this.escapeHtml(seg.content))
207
+ }
208
+ }
209
+ return result
210
+ }
211
+
212
+ highlightTextSegment(escaped) {
213
+ let result = escaped
214
+
215
+ // Escape sequences: dim the backslash before markdown punctuation
216
+ result = result.replace(/\\([\\`*_{}[\]()#+\-.!~|])/g,
217
+ '<span style="color:rgba(128,128,128,0.4)">\\</span>$1')
218
+
219
+ // Unordered list markers with optional task list checkbox
220
+ result = result.replace(/^(\t*)(- )(\[[ xX]\] )?/, (_, tabs, marker, task) => {
221
+ let r = tabs + '<span style="color:rgba(100,200,150,0.7)">' + marker + '</span>'
222
+ if (task) {
223
+ r += '<span style="color:rgba(100,200,150,0.7)">' + task + '</span>'
224
+ }
225
+ return r
226
+ })
227
+
228
+ // Ordered list markers
229
+ result = result.replace(/^(\t*)(\d+\. )/, (_, tabs, marker) =>
230
+ tabs + '<span style="color:rgba(100,200,150,0.7)">' + marker + '</span>')
231
+
232
+ // Images ![alt](url) and Links [text](url)
233
+ result = result.replace(/(!?\[)(.*?)(\]\()(.+?)(\))/g,
234
+ '<span style="color:rgba(100,180,220,0.5)">$1</span><span style="color:rgba(100,180,220,0.8)">$2</span><span style="color:rgba(100,180,220,0.5)">$3</span><span style="color:rgba(100,180,220,0.6)">$4</span><span style="color:rgba(100,180,220,0.5)">$5</span>')
235
+
236
+ // Reference links [text][ref]
237
+ result = result.replace(/(\[)(.*?)(\]\[)(.*?)(\])/g,
238
+ '<span style="color:rgba(100,180,220,0.5)">$1</span><span style="color:rgba(100,180,220,0.8)">$2</span><span style="color:rgba(100,180,220,0.5)">$3</span><span style="color:rgba(100,180,220,0.6)">$4</span><span style="color:rgba(100,180,220,0.5)">$5</span>')
239
+
240
+ // Strikethrough ~~text~~
241
+ result = result.replace(/(~~)(.*?)(~~)/g,
242
+ '<span style="color:rgba(255,100,100,0.5)">$1</span><span style="color:rgba(255,100,100,0.7)">$2</span><span style="color:rgba(255,100,100,0.5)">$3</span>')
243
+
244
+ // Bold **text**
245
+ result = result.replace(/(\*\*)(.*?)(\*\*)/g,
246
+ '<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>')
247
+
248
+ // Italic _text_
249
+ result = result.replace(/((?:^|[^\\]))(\_)(.*?[^\\])(\_)/g,
250
+ '$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>')
251
+
252
+ // HTML tags
253
+ result = result.replace(/(&lt;)(\/?[a-zA-Z]\w*)(.*?)(&gt;)/g,
254
+ '<span style="color:rgba(200,120,120,0.5)">$1$2$3$4</span>')
255
+
256
+ return result
257
+ }
258
+
259
+ toggleWrapMode() {
260
+ this.wrapEnabled = !this.wrapEnabled
261
+ const wrap = this.wrapEnabled
262
+ this.element.style.whiteSpace = wrap ? 'pre-wrap' : 'pre'
263
+ this.element.style.overflowX = wrap ? 'hidden' : 'auto'
264
+ this.highlightLayer.style.whiteSpace = wrap ? 'pre-wrap' : 'pre'
265
+ this.highlightLayer.style.overflowWrap = wrap ? 'break-word' : 'normal'
266
+ this.wrapButton.style.opacity = wrap ? '0.9' : '0.4'
267
+ this.updateHighlight()
268
+ }
269
+
132
270
  escapeHtml(str) {
133
271
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
134
272
  }