Skip to content

Commit

Permalink
feat: markdown parser for links and lists
Browse files Browse the repository at this point in the history
  • Loading branch information
pateljannat committed Dec 16, 2024
1 parent d88aaed commit 7f44177
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 117 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -67,7 +67,7 @@ In 2021, we were looking for a Learning Management System to launch [Mon.School]
</details>


## Under the Hood
### Under the Hood

- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework.

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/CourseCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@

<div v-if="course.status != 'Approved'">
<Badge
variant="solid"
variant="subtle"
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
size="sm"
>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Modals/ExplanationVideos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const props = defineProps({
required: true,
},
title: {
type: String,
type: [String, null],
required: true,
},
})
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/LessonForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const renderEditor = (holder) => {
holder: holder,
tools: getEditorTools(true),
autofocus: true,
defaultBlock: 'markdownParser',
})
}
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -184,7 +187,7 @@ export function getEditorTools() {
},
embed: {
class: Embed,
inlineToolbar: false,
inlineToolbar: true,
config: {
services: {
youtube: {
Expand Down
224 changes: 116 additions & 108 deletions frontend/src/utils/markdownParser.js
Original file line number Diff line number Diff line change
@@ -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 = `<a href="${url}" target="_blank">${text}</a>`
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)
}
}

Expand Down

0 comments on commit 7f44177

Please sign in to comment.