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 +1 -1
- package/package.json +1 -1
- package/src/MdEditor.js +225 -17
package/index.html
CHANGED
package/package.json
CHANGED
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
265
|
+
const matchEmptyUl = currentLine.match(/^(\s*- )$/)
|
|
266
|
+
const matchEmptyOl = currentLine.match(/^(\s*)\d+\. $/)
|
|
66
267
|
const matchHyphen = currentLine.match(/^(\s*- )/)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.element.selectionStart = this.element.selectionEnd -
|
|
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
|
-
|
|
73
|
-
|
|
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)
|