cm-md-editor 1.0.4 → 2.0.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.
package/index.html CHANGED
@@ -9,7 +9,7 @@
9
9
  <style>
10
10
  #editor {
11
11
  width: 100%;
12
- height: calc(100vh - 4rem);
12
+ height: calc(100vh - 6rem);
13
13
  padding: 0.7rem;
14
14
  font-family: monospace;
15
15
  tab-size: 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cm-md-editor",
3
- "version": "1.0.4",
3
+ "version": "2.0.0",
4
4
  "description": "a simple markdown editor",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/MdEditor.js CHANGED
@@ -7,6 +7,214 @@ export class MdEditor {
7
7
  constructor(element) {
8
8
  this.element = element
9
9
  this.element.addEventListener('keydown', (e) => this.handleKeyDown(e))
10
+ this.createToolbar()
11
+ this.createHighlightBackdrop()
12
+ }
13
+
14
+ createToolbar() {
15
+ const wrapper = document.createElement('div')
16
+ this.element.parentNode.insertBefore(wrapper, this.element)
17
+ wrapper.appendChild(this.element)
18
+ const toolbar = document.createElement('div')
19
+ 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
+ wrapper.insertBefore(toolbar, this.element)
21
+ 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()},
30
+ ]
31
+ buttons.forEach(btn => {
32
+ const button = document.createElement('button')
33
+ button.type = 'button'
34
+ button.title = btn.title
35
+ 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>`
37
+ button.addEventListener('mouseenter', () => { button.style.opacity = '1'; button.style.background = 'rgba(128,128,128,0.2)' })
38
+ button.addEventListener('mouseleave', () => { button.style.opacity = '0.6'; button.style.background = 'none' })
39
+ button.addEventListener('mousedown', (e) => {
40
+ e.preventDefault()
41
+ })
42
+ button.addEventListener('click', (e) => {
43
+ e.preventDefault()
44
+ btn.action()
45
+ })
46
+ toolbar.appendChild(button)
47
+ })
48
+ }
49
+
50
+ createHighlightBackdrop() {
51
+ const container = this.element.parentNode
52
+ container.style.position = 'relative'
53
+ this.backdrop = document.createElement('div')
54
+ this.highlightLayer = document.createElement('div')
55
+ this.backdrop.appendChild(this.highlightLayer)
56
+ container.appendChild(this.backdrop)
57
+
58
+ // Copy textarea computed styles to backdrop
59
+ const cs = window.getComputedStyle(this.element)
60
+ this.backdrop.style.cssText = `position:absolute;overflow:hidden;pointer-events:none;z-index:1;`
61
+ this.highlightLayer.style.cssText = `white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;color:transparent;`
62
+
63
+ const syncStyles = () => {
64
+ const cs = window.getComputedStyle(this.element)
65
+ const props = ['font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing',
66
+ 'tab-size', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left']
67
+ props.forEach(p => this.highlightLayer.style[p.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = cs.getPropertyValue(p))
68
+ this.highlightLayer.style.boxSizing = 'border-box'
69
+ // Use clientWidth to match the textarea's content area (excludes scrollbar)
70
+ this.highlightLayer.style.width = this.element.clientWidth + 'px'
71
+ const borderTop = parseInt(cs.getPropertyValue('border-top-width')) || 0
72
+ const borderLeft = parseInt(cs.getPropertyValue('border-left-width')) || 0
73
+ this.backdrop.style.top = (this.element.offsetTop + borderTop) + 'px'
74
+ this.backdrop.style.left = (this.element.offsetLeft + borderLeft) + 'px'
75
+ this.backdrop.style.width = this.element.clientWidth + 'px'
76
+ this.backdrop.style.height = this.element.clientHeight + 'px'
77
+ }
78
+ syncStyles()
79
+
80
+ // Make textarea background transparent so backdrop shows through
81
+ this.element.style.background = 'transparent'
82
+ this.element.style.position = 'relative'
83
+ this.element.style.caretColor = cs.color
84
+
85
+ // Sync scroll
86
+ this.element.addEventListener('scroll', () => {
87
+ this.highlightLayer.style.transform = `translate(0, -${this.element.scrollTop}px)`
88
+ })
89
+
90
+ // Update on input
91
+ this.element.addEventListener('input', () => this.updateHighlight())
92
+ new ResizeObserver(() => { syncStyles(); this.updateHighlight() }).observe(this.element)
93
+ this.updateHighlight()
94
+ }
95
+
96
+ updateHighlight() {
97
+ const text = this.element.value
98
+ const lines = text.split('\n')
99
+ let html = ''
100
+ for (let i = 0; i < lines.length; i++) {
101
+ if (i > 0) html += '\n'
102
+ let line = lines[i]
103
+ let result = this.escapeHtml(line)
104
+
105
+ // Headings: emphasize entire line
106
+ const headingMatch = line.match(/^(#{1,6}) /)
107
+ 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>')
125
+ }
126
+ html += result
127
+ }
128
+ // Trailing newline so the backdrop height matches the textarea
129
+ this.highlightLayer.innerHTML = html + '\n'
130
+ }
131
+
132
+ escapeHtml(str) {
133
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
134
+ }
135
+
136
+ getCurrentLineInfo() {
137
+ const start = this.element.selectionStart
138
+ const text = this.element.value
139
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1
140
+ let lineEnd = text.indexOf('\n', start)
141
+ if (lineEnd === -1) lineEnd = text.length
142
+ const line = text.substring(lineStart, lineEnd)
143
+ return {lineStart, lineEnd, line}
144
+ }
145
+
146
+ selectLineRange(lineStart, lineEnd) {
147
+ this.element.selectionStart = lineStart
148
+ this.element.selectionEnd = lineEnd
149
+ }
150
+
151
+ toggleHeading(level) {
152
+ const {lineStart, lineEnd, line} = this.getCurrentLineInfo()
153
+ const prefix = '#'.repeat(level) + ' '
154
+ const headingMatch = line.match(/^(#{1,6}) /)
155
+ this.selectLineRange(lineStart, lineEnd)
156
+ if (headingMatch && headingMatch[1].length === level) {
157
+ this.insertTextAtCursor(line.substring(prefix.length))
158
+ } else if (headingMatch) {
159
+ this.insertTextAtCursor(prefix + line.substring(headingMatch[0].length))
160
+ } else {
161
+ this.insertTextAtCursor(prefix + line)
162
+ }
163
+ }
164
+
165
+ toggleWrap(marker) {
166
+ const start = this.element.selectionStart
167
+ const end = this.element.selectionEnd
168
+ const text = this.element.value
169
+ const len = marker.length
170
+ const before = text.substring(start - len, start)
171
+ const after = text.substring(end, end + len)
172
+ if (before === marker && after === marker) {
173
+ // Remove markers, keep selection on the inner text
174
+ this.element.selectionStart = start - len
175
+ this.element.selectionEnd = end + len
176
+ const selected = text.substring(start, end)
177
+ this.insertTextAtCursor(selected)
178
+ this.element.selectionStart = start - len
179
+ this.element.selectionEnd = end - len
180
+ } else if (start !== end) {
181
+ // Wrap selection, keep selection on the inner text
182
+ this.insertTextAtCursor(marker + text.substring(start, end) + marker)
183
+ this.element.selectionStart = start + len
184
+ this.element.selectionEnd = end + len
185
+ } else {
186
+ this.insertTextAtCursor(marker + marker)
187
+ this.element.selectionStart = this.element.selectionEnd = start + len
188
+ }
189
+ }
190
+
191
+ toggleBold() {
192
+ this.toggleWrap('**')
193
+ }
194
+
195
+ toggleItalic() {
196
+ this.toggleWrap('_')
197
+ }
198
+
199
+ insertUnorderedList() {
200
+ const {lineStart, lineEnd, line} = this.getCurrentLineInfo()
201
+ this.selectLineRange(lineStart, lineEnd)
202
+ if (line.startsWith('- ')) {
203
+ this.insertTextAtCursor(line.substring(2))
204
+ } else {
205
+ this.insertTextAtCursor('- ' + line)
206
+ }
207
+ }
208
+
209
+ insertOrderedList() {
210
+ const {lineStart, lineEnd, line} = this.getCurrentLineInfo()
211
+ this.selectLineRange(lineStart, lineEnd)
212
+ const olMatch = line.match(/^\d+\. /)
213
+ if (olMatch) {
214
+ this.insertTextAtCursor(line.substring(olMatch[0].length))
215
+ } else {
216
+ this.insertTextAtCursor('1. ' + line)
217
+ }
10
218
  }
11
219
 
12
220
  insertTextAtCursor(text) {
@@ -20,7 +228,7 @@ export class MdEditor {
20
228
  const before = this.element.value.substring(0, start)
21
229
  const selected = this.element.value.substring(start, end)
22
230
  const currentLine = before.substring(before.lastIndexOf('\n') + 1)
23
- const isListMode = currentLine.match(/^\t*- /)
231
+ const isListMode = currentLine.match(/^\t*- /) || currentLine.match(/^\t*\d+\. /)
24
232
  if (e.key === 'Tab') {
25
233
  e.preventDefault()
26
234
  if (isListMode) {
@@ -37,18 +245,10 @@ export class MdEditor {
37
245
  } else if (e.ctrlKey || e.metaKey) {
38
246
  if (e.key === 'b') { // bold
39
247
  e.preventDefault()
40
- if (selected) {
41
- this.insertTextAtCursor('**' + selected + '**')
42
- } else {
43
- this.insertTextAtCursor('**')
44
- }
248
+ this.toggleBold()
45
249
  } else if (e.key === 'i') { // italic
46
250
  e.preventDefault()
47
- if (selected) {
48
- this.insertTextAtCursor('_' + selected + '_')
49
- } else {
50
- this.insertTextAtCursor('_')
51
- }
251
+ this.toggleItalic()
52
252
  } else if (e.key === 'e') { // game todo this could be an extension
53
253
  e.preventDefault()
54
254
  this.insertTextAtCursor('[game id="' + selected + '"]')
@@ -62,15 +262,21 @@ export class MdEditor {
62
262
  const start = this.element.selectionStart
63
263
  const before = this.element.value.substring(0, start)
64
264
  const currentLine = before.substring(before.lastIndexOf('\n') + 1)
65
- const matchEmpty = currentLine.match(/^(\s*- )$/)
265
+ const matchEmptyUl = currentLine.match(/^(\s*- )$/)
266
+ const matchEmptyOl = currentLine.match(/^(\s*)\d+\. $/)
66
267
  const matchHyphen = currentLine.match(/^(\s*- )/)
67
- if (matchEmpty) {
68
- const pre = matchEmpty[1]
69
- this.element.selectionStart = this.element.selectionEnd - pre.length - 1
268
+ const matchOl = currentLine.match(/^(\s*)(\d+)\. ./)
269
+ if (matchEmptyUl) {
270
+ this.element.selectionStart = this.element.selectionEnd - matchEmptyUl[1].length - 1
271
+ } else if (matchEmptyOl) {
272
+ this.element.selectionStart = this.element.selectionEnd - matchEmptyOl[0].length - 1
70
273
  } else if (matchHyphen) {
71
274
  e.preventDefault()
72
- const pre = matchHyphen[1]
73
- this.insertTextAtCursor('\n' + pre)
275
+ this.insertTextAtCursor('\n' + matchHyphen[1])
276
+ } else if (matchOl) {
277
+ e.preventDefault()
278
+ const nextNum = parseInt(matchOl[2]) + 1
279
+ this.insertTextAtCursor('\n' + matchOl[1] + nextNum + '. ')
74
280
  }
75
281
  }
76
282
 
@@ -87,6 +293,8 @@ export class MdEditor {
87
293
  this.element.selectionStart = this.element.selectionEnd = start + 1
88
294
  }
89
295
 
296
+ // test
297
+
90
298
  removeTab() {
91
299
  const start = this.element.selectionStart
92
300
  const before = this.element.value.substring(0, start)