import { CodeXml } from 'lucide-vue-next'
import { createApp, h } from 'vue'
export class Markdown {
constructor({ data, api, readOnly, config }) {
this.api = api
this.data = data || {}
this.config = config || {}
this.readOnly = readOnly
this.text = data.text || ''
this.placeholder = __("Type '/' for commands or select text to format")
}
static get isReadOnlySupported() {
return true
}
static get conversionConfig() {
return {
export: 'text',
import: 'text',
}
}
static get toolbox() {
const app = createApp({
render: () =>
h(CodeXml, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return { title: '', icon: div.innerHTML }
}
static get pasteConfig() {
return {
tags: ['P'],
}
}
render() {
this.wrapper = document.createElement('div')
this.wrapper.classList.add('cdx-block', 'ce-paragraph')
this.wrapper.contentEditable = !this.readOnly
this.wrapper.dataset.placeholder = this.placeholder
this.wrapper.innerHTML = this.text
if (!this.readOnly) {
this.wrapper.addEventListener('focus', () =>
this._togglePlaceholder()
)
this.wrapper.addEventListener('blur', () =>
this._togglePlaceholder()
)
this.wrapper.addEventListener('keydown', (e) => this._onKeyDown(e))
this.wrapper.addEventListener(
'paste',
(e) => this._onNativePaste(e),
true
)
}
return this.wrapper
}
_onNativePaste(event) {
const clipboardData = event.clipboardData || window.clipboardData
if (!clipboardData) return
const pastedText = clipboardData.getData('text/plain')
if (pastedText && this._looksLikeMarkdown(pastedText)) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
this._insertMarkdownAsBlocks(pastedText)
}
}
_looksLikeMarkdown(text) {
const markdownPatterns = [
/^#{1,6}\s+/m,
/^[\-\*]\s+/m,
/^\d+\.\s+/m,
/```[\s\S]*```/,
]
return markdownPatterns.some((pattern) => pattern.test(text))
}
async _insertMarkdownAsBlocks(markdown) {
const blocks = this._parseMarkdownToBlocks(markdown)
if (blocks.length === 0) return
const currentIndex = this.api.blocks.getCurrentBlockIndex()
for (let i = 0; i < blocks.length; i++) {
try {
await this.api.blocks.insert(
blocks[i].type,
blocks[i].data,
{},
currentIndex + i,
false
)
} catch (error) {
console.error('Failed to insert block:', blocks[i], error)
}
}
try {
await this.api.blocks.delete(currentIndex + blocks.length)
} catch (error) {
console.error('Failed to delete original block:', error)
}
setTimeout(() => {
this.api.caret.setToBlock(currentIndex, 'end')
}, 100)
}
_parseMarkdownToBlocks(markdown) {
const lines = markdown.split('\n')
const blocks = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (line.trim() === '') {
i++
continue
}
if (line.trim().startsWith('```')) {
const codeBlock = this._parseCodeBlock(lines, i)
blocks.push(codeBlock.block)
i = codeBlock.nextIndex
continue
}
if (/^#{1,6}\s+/.test(line)) {
blocks.push(this._parseHeading(line))
i++
continue
}
if (/^[\s]*[-*+]\s+/.test(line)) {
const listBlock = this._parseUnorderedList(lines, i)
blocks.push(listBlock.block)
i = listBlock.nextIndex
continue
}
if (/^[\s]*(\d+)\.\s+/.test(line)) {
const listBlock = this._parseOrderedList(lines, i)
blocks.push(listBlock.block)
i = listBlock.nextIndex
continue
}
blocks.push({
type: 'paragraph',
data: { text: this._parseInlineMarkdown(line) },
})
i++
}
return blocks
}
_parseHeading(line) {
const match = line.match(/^(#{1,6})\s+(.*)$/)
const level = match[1].length
const text = match[2]
return {
type: 'header',
data: {
text: this._parseInlineMarkdown(text),
level: level,
},
}
}
_parseUnorderedList(lines, startIndex) {
const items = []
let i = startIndex
while (i < lines.length) {
const line = lines[i]
if (/^[\s]*[-*+]\s+/.test(line)) {
const text = line.replace(/^[\s]*[-*+]\s+/, '')
items.push({
content: this._parseInlineMarkdown(text),
items: [],
})
i++
} else if (line.trim() === '') {
i++
if (i < lines.length && /^[\s]*[-*+]\s+/.test(lines[i])) {
continue
} else {
break
}
} else {
break
}
}
return {
block: {
type: 'list',
data: {
style: 'unordered',
items: items,
},
},
nextIndex: i,
}
}
_parseOrderedList(lines, startIndex) {
const items = []
let i = startIndex
while (i < lines.length) {
const line = lines[i]
const match = line.match(/^[\s]*(\d+)\.\s+(.*)$/)
if (match) {
const number = match[1]
const text = match[2]
if (number === '1') {
if (items.length > 0) {
break
}
}
items.push({
content: this._parseInlineMarkdown(text),
items: [],
})
i++
} else if (line.trim() === '') {
i++
if (i < lines.length && /^[\s]*(\d+)\.\s+/.test(lines[i])) {
continue
} else {
break
}
} else {
break
}
}
return {
block: {
type: 'list',
data: {
style: 'ordered',
items: items,
},
},
nextIndex: i,
}
}
_parseCodeBlock(lines, startIndex) {
let i = startIndex + 1
const codeLines = []
let language = lines[startIndex].trim().substring(3).trim()
while (i < lines.length) {
if (lines[i].trim().startsWith('```')) {
i++
break
}
codeLines.push(lines[i])
i++
}
return {
block: {
type: 'codeBox',
data: {
code: codeLines.join('\n'),
language: language || 'plaintext',
},
},
nextIndex: i,
}
}
_parseInlineMarkdown(text) {
if (!text) return ''
let html = this._escapeHtml(text)
html = html.replace(/`([^`]+)`/g, '$1')
html = html.replace(/\*\*([^\*\n]+?)\*\*/g, '$1')
html = html.replace(/__([^_\n]+?)__/g, '$1')
html = html.replace(/\*([^\*\n]+?)\*/g, '$1')
html = html.replace(/(?$1')
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
return html
}
_escapeHtml(text) {
return text
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
_togglePlaceholder() {
const blocks = document.querySelectorAll(
'.cdx-block.ce-paragraph[data-placeholder]'
)
blocks.forEach((block) => {
if (block !== this.wrapper) delete block.dataset.placeholder
})
if (this.wrapper.innerHTML.trim() === '') {
this.wrapper.dataset.placeholder = this.placeholder
} else {
delete this.wrapper.dataset.placeholder
}
}
_onKeyDown(event) {
const text = this.wrapper.textContent
if (event.key === ' ' && /^#{1,6}$/.test(text)) {
event.preventDefault()
const level = text.length
this.wrapper.textContent = ''
this._convertBlock('header', { level })
} else if (event.key === ' ' && text === '-') {
event.preventDefault()
this.wrapper.textContent = ''
this._convertBlock('list', {
style: 'unordered',
items: [{ content: '' }],
})
} else if (event.key === ' ' && /^1\.$/.test(text)) {
event.preventDefault()
this.wrapper.textContent = ''
this._convertBlock('list', {
style: 'ordered',
items: [{ content: '' }],
})
} else if (this._isEmbed(text) && event.key === 'Enter') {
event.preventDefault()
this.wrapper.textContent = ''
this._convertBlock('embed', { source: text })
} else if (event.key === 'Enter') {
setTimeout(() => this._checkMarkdownAfterEnter(), 0)
}
}
_checkMarkdownAfterEnter() {
const text = this.wrapper.textContent.trim()
if (this._isImage(text)) {
this._convertBlock('image', {
file: { url: this._extractImage(text).url },
})
}
}
async _convertBlock(type, data) {
const currentIndex = this.api.blocks.getCurrentBlockIndex()
const currentBlock = this.api.blocks.getBlockByIndex(currentIndex)
if (!currentBlock) return
await this.api.blocks.convert(currentBlock.id, type, data)
setTimeout(() => {
const newIndex = this.api.blocks.getCurrentBlockIndex()
const newBlock = this.api.blocks.getBlockByIndex(newIndex)
if (newBlock && newBlock.holder) {
const holder = newBlock.holder.querySelector(
'[contenteditable="true"]'
)
if (holder) {
holder.focus()
// Place caret at end
const range = document.createRange()
range.selectNodeContents(holder)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
} else {
this.api.caret.focus(true)
}
} else {
this.api.caret.focus(true)
}
}, 0)
}
save(blockContent) {
return { text: blockContent.innerHTML }
}
_isImage(text) {
return /!\[.+?\]\(.+?\)/.test(text)
}
_extractImage(text) {
const match = text.match(/!\[(.+?)\]\((.+?)\)/)
if (match) return { alt: match[1], url: match[2] }
return { alt: '', url: '' }
}
_isLink(text) {
return /\[.+?\]\(.+?\)/.test(text)
}
_extractLink(text) {
const match = text.match(/\[(.+?)\]\((.+?)\)/)
if (match) return { text: match[1], url: match[2] }
return { text: '', url: '' }
}
_isEmbed(text) {
return /^https?:\/\/.+/.test(text.trim())
}
}
export default Markdown