diff --git a/README.md b/README.md index be054c583..6b06f4769 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ ## Frappe Learning Frappe Learning is an easy-to-use learning system that helps you bring structure to your content. -## Motivation +### Motivation In 2021, we were looking for a Learning Management System to launch [Mon.School](https://mon.school) for FOSS United. We checked out Moodle, but it didn’t feel right. The forms were unnecessarily lengthy and the UI was confusing. It shouldn't be this hard to create a course right? So I started making a learning system for Mon.School which soon became a product in itself. The aim is to have a simple platform that anyone can use to launch a course of their own and make knowledge sharing easier. -## Key Features +### Key Features - **Structured Learning**: Design a course with a 3-level hierarchy, where your courses have chapters and you can group your lessons within these chapters. This ensures that the context of the lesson is set by the chapter. @@ -67,7 +67,7 @@ In 2021, we were looking for a Learning Management System to launch [Mon.School] -## Under the Hood +### Under the Hood - [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework. diff --git a/frontend/src/components/CourseCard.vue b/frontend/src/components/CourseCard.vue index 50673e82e..790153c4e 100644 --- a/frontend/src/components/CourseCard.vue +++ b/frontend/src/components/CourseCard.vue @@ -59,7 +59,7 @@
diff --git a/frontend/src/components/Modals/ExplanationVideos.vue b/frontend/src/components/Modals/ExplanationVideos.vue index b6e7ceef4..2f362cb49 100644 --- a/frontend/src/components/Modals/ExplanationVideos.vue +++ b/frontend/src/components/Modals/ExplanationVideos.vue @@ -26,7 +26,7 @@ const props = defineProps({ required: true, }, title: { - type: String, + type: [String, null], required: true, }, }) diff --git a/frontend/src/pages/LessonForm.vue b/frontend/src/pages/LessonForm.vue index ed83bf049..ee1aee23f 100644 --- a/frontend/src/pages/LessonForm.vue +++ b/frontend/src/pages/LessonForm.vue @@ -132,6 +132,7 @@ const renderEditor = (holder) => { holder: holder, tools: getEditorTools(true), autofocus: true, + defaultBlock: 'markdownParser', }) } diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index d0d9e8a78..789005dcd 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -13,8 +13,6 @@ import dayjs from '@/utils/dayjs' import Embed from '@editorjs/embed' import SimpleImage from '@editorjs/simple-image' import Table from '@editorjs/table' -import MDParser from 'editorjs-md-parser' -import MDImporter from 'editorjs-md-parser' export function createToast(options) { toast({ @@ -150,7 +148,12 @@ export function htmlToText(html) { export function getEditorTools() { return { - header: Header, + header: { + class: Header, + config: { + placeholder: 'Header', + }, + }, quiz: Quiz, upload: Upload, markdownParser: MarkdownParser, @@ -184,7 +187,7 @@ export function getEditorTools() { }, embed: { class: Embed, - inlineToolbar: false, + inlineToolbar: true, config: { services: { youtube: { diff --git a/frontend/src/utils/markdownParser.js b/frontend/src/utils/markdownParser.js index 2d8dbcb99..64b383bc3 100644 --- a/frontend/src/utils/markdownParser.js +++ b/frontend/src/utils/markdownParser.js @@ -1,143 +1,151 @@ export class MarkdownParser { constructor({ data, api, readOnly, config }) { - console.log('markdownParser constructor called') this.api = api this.data = data || {} this.config = config || {} - this.text = this.data.text || '' + this.text = data.text || '' this.readOnly = readOnly } - static get toolbox() { - const app = createApp({ - render: () => - h(UploadIcon, { size: 18, strokeWidth: 1.5, color: 'black' }), - }) - - const div = document.createElement('div') - app.mount(div) + static get isReadOnlySupported() { + return true + } + static get conversionConfig() { return { - title: 'Upload', - icon: div.innerHTML, + export: 'text', + import: 'text', } } - static get isReadOnlySupported() { - return true - } - render() { - console.log(' render() called') - const container = document.createElement('div') - container.contentEditable = true // Make the div editable like a textarea - container.classList.add('markdown-parser') - container.textContent = this.text - - container.addEventListener('blur', () => { - this.text = container.textContent.trim() - this.parseMarkdown() - }) - - this.textArea = container - return container + this.wrapper = document.createElement('div') + this.wrapper.classList.add('cdx-block') + this.wrapper.classList.add('ce-paragraph') + this.wrapper.innerHTML = this.text + + if (!this.readOnly) { + this.wrapper.contentEditable = true + this.wrapper.innerHTML = this.text + + this.wrapper.addEventListener('keydown', (event) => { + const value = event.target.textContent + if (event.keyCode === 32 && value.startsWith('#')) { + this.convertToHeader(event, value) + } else if (event.keyCode === 13) { + this.parseContent(event) + } + }) + + this.wrapper.addEventListener('paste', (event) => + this.handlePaste(event) + ) + } + + return this.wrapper } - save(blockContent) { - return { - text: this.text, + convertToHeader(event, value) { + event.preventDefault() + if (['#', '##', '###', '####', '#####', '######'].includes(value)) { + let level = value.length + event.target.textContent = '' + this.convertBlock('header', { + level: level, + }) } } - /** - * Parse Markdown text and render Editor.js blocks. - */ - parseMarkdown() { - console.log(' parseMarkdown() called') - const markdown = this.text - const lines = markdown.split('\n') - - const blocks = lines.map((line) => { - if (line.startsWith('# ')) { - return { - type: 'header', - data: { text: line.replace('# ', ''), level: 1 }, - } - } else if (line.startsWith('## ')) { - return { - type: 'header', - data: { text: line.replace('## ', ''), level: 2 }, - } - } else if (line.startsWith('- ')) { - return { - type: 'list', - data: { - items: [line.replace('- ', '')], - style: 'unordered', + parseContent(event) { + event.preventDefault() + const previousLine = this.wrapper.textContent + if (previousLine && this.hasImage(previousLine)) { + this.wrapper.textContent = '' + this.convertBlock('image') + } else if (previousLine && this.hasLink(previousLine)) { + const { text, url } = this.extractLink(previousLine) + const anchorTag = `${text}` + this.convertBlock('paragraph', { + text: previousLine.replace(/\[.+?\]\(.+?\)/, anchorTag), + }) + } else if (previousLine && previousLine.startsWith('- ')) { + this.convertBlock('list', { + style: 'unordered', + items: [ + { + content: previousLine.replace('- ', ''), }, - } - } else if (this.isImage(line)) { - const { alt, url } = this.extractImage(line) - return { - type: 'image', - data: { - file: { url }, - caption: alt, - withBorder: false, - stretched: false, - withBackground: false, + ], + }) + } else if (previousLine && previousLine.startsWith('1. ')) { + this.convertBlock('list', { + style: 'ordered', + items: [ + { + content: previousLine.replace('1. ', ''), }, - } - } else if (this.isLink(line)) { - const { text, url } = this.extractLink(line) - return { - type: 'linkTool', - data: { link: url, meta: { title: text } }, - } - } else { - return { type: 'paragraph', data: { text: line } } - } - }) + ], + }) + } else if (previousLine && this.canBeEmbed(previousLine)) { + this.wrapper.textContent = '' + this.convertBlock('embed', { + source: previousLine, + }) + } + } - this.api.blocks.render({ blocks }) + async convertBlock(type, data, index = null) { + const currentIndex = this.api.blocks.getCurrentBlockIndex() + const currentBlock = this.api.blocks.getBlockByIndex(currentIndex) + await this.api.blocks.convert(currentBlock.id, type, data) + this.api.caret.focus(true) } - /** - * Check if the line matches the image syntax. - * @param {string} line - The line of text. - * @returns {boolean} - */ - isImage(line) { - return /^!\[.*\]\(.*\)$/.test(line) + handlePaste(event) { + event.preventDefault() + + const clipboardData = event.clipboardData || window.clipboardData + const pastedText = clipboardData.getData('text/plain') + const sanitizedText = this.processPastedContent(pastedText) + document.execCommand('insertText', false, sanitizedText) + } + + processPastedContent(text) { + return text.trim() + } + + save(blockContent) { + return { + text: blockContent.innerHTML, + } + } + + hasImage(line) { + return /!\[.+?\]\(.+?\)/.test(line) } - /** - * Extract alt text and URL from the image syntax. - * @param {string} line - The line of text. - * @returns {Object} { alt, url } - */ extractImage(line) { - const match = line.match(/^!\[(.*)\]\((.*)\)$/) - return { alt: match[1], url: match[2] } + const match = line.match(/!\[(.+?)\]\((.+?)\)/) + if (match) { + return { alt: match[1], url: match[2] } + } + return { alt: '', url: '' } } - /** - * Check if the line matches the link syntax. - * @param {string} line - The line of text. - * @returns {boolean} - */ - isLink(line) { - return /^\[.*\]\(.*\)$/.test(line) + hasLink(line) { + return /\[.+?\]\(.+?\)/.test(line) } - /** - * Extract text and URL from the link syntax. - * @param {string} line - The line of text. - * @returns {Object} { text, url } - */ extractLink(line) { - const match = line.match(/^\[(.*)\]\((.*)\)$/) - return { text: match[1], url: match[2] } + const match = line.match(/\[(.+?)\]\((.+?)\)/) + if (match) { + return { text: match[1], url: match[2] } + } + return { text: '', url: '' } + } + + canBeEmbed(line) { + return /^https?:\/\/.+/.test(line) } }