diff --git a/frontend/src/utils/markdownParser.js b/frontend/src/utils/markdownParser.js index a5b10ae7..493bcb47 100644 --- a/frontend/src/utils/markdownParser.js +++ b/frontend/src/utils/markdownParser.js @@ -34,7 +34,9 @@ export class Markdown { } static get pasteConfig() { - return { tags: ['P'] } + return { + tags: ['P'], + } } render() { @@ -52,11 +54,277 @@ export class Markdown { 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]' diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 88bc96d0..c5fc4acf 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -8,7 +8,6 @@ from frappe import _ from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_result from frappe.desk.doctype.notification_log.notification_log import make_notification_logs from frappe.desk.notifications import extract_mentions -from frappe.pulse.utils import get_frappe_version from frappe.rate_limiter import rate_limit from frappe.utils import ( add_months, @@ -17,6 +16,7 @@ from frappe.utils import ( fmt_money, format_datetime, get_datetime, + get_frappe_version, get_fullname, get_time_str, getdate,