feat: markdown parser for links and lists
This commit is contained in:
@@ -23,10 +23,10 @@
|
|||||||
## Frappe Learning
|
## Frappe Learning
|
||||||
Frappe Learning is an easy-to-use learning system that helps you bring structure to your content.
|
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.
|
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.
|
- **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]
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
## Under the Hood
|
### Under the Hood
|
||||||
|
|
||||||
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework.
|
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework.
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
<div v-if="course.status != 'Approved'">
|
<div v-if="course.status != 'Approved'">
|
||||||
<Badge
|
<Badge
|
||||||
variant="solid"
|
variant="subtle"
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: [String, null],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ const renderEditor = (holder) => {
|
|||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(true),
|
tools: getEditorTools(true),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
defaultBlock: 'markdownParser',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import dayjs from '@/utils/dayjs'
|
|||||||
import Embed from '@editorjs/embed'
|
import Embed from '@editorjs/embed'
|
||||||
import SimpleImage from '@editorjs/simple-image'
|
import SimpleImage from '@editorjs/simple-image'
|
||||||
import Table from '@editorjs/table'
|
import Table from '@editorjs/table'
|
||||||
import MDParser from 'editorjs-md-parser'
|
|
||||||
import MDImporter from 'editorjs-md-parser'
|
|
||||||
|
|
||||||
export function createToast(options) {
|
export function createToast(options) {
|
||||||
toast({
|
toast({
|
||||||
@@ -150,7 +148,12 @@ export function htmlToText(html) {
|
|||||||
|
|
||||||
export function getEditorTools() {
|
export function getEditorTools() {
|
||||||
return {
|
return {
|
||||||
header: Header,
|
header: {
|
||||||
|
class: Header,
|
||||||
|
config: {
|
||||||
|
placeholder: 'Header',
|
||||||
|
},
|
||||||
|
},
|
||||||
quiz: Quiz,
|
quiz: Quiz,
|
||||||
upload: Upload,
|
upload: Upload,
|
||||||
markdownParser: MarkdownParser,
|
markdownParser: MarkdownParser,
|
||||||
@@ -184,7 +187,7 @@ export function getEditorTools() {
|
|||||||
},
|
},
|
||||||
embed: {
|
embed: {
|
||||||
class: Embed,
|
class: Embed,
|
||||||
inlineToolbar: false,
|
inlineToolbar: true,
|
||||||
config: {
|
config: {
|
||||||
services: {
|
services: {
|
||||||
youtube: {
|
youtube: {
|
||||||
|
|||||||
@@ -1,143 +1,151 @@
|
|||||||
export class MarkdownParser {
|
export class MarkdownParser {
|
||||||
constructor({ data, api, readOnly, config }) {
|
constructor({ data, api, readOnly, config }) {
|
||||||
console.log('markdownParser constructor called')
|
|
||||||
this.api = api
|
this.api = api
|
||||||
this.data = data || {}
|
this.data = data || {}
|
||||||
this.config = config || {}
|
this.config = config || {}
|
||||||
this.text = this.data.text || ''
|
this.text = data.text || ''
|
||||||
this.readOnly = readOnly
|
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)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: 'Upload',
|
|
||||||
icon: div.innerHTML,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static get isReadOnlySupported() {
|
static get isReadOnlySupported() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get conversionConfig() {
|
||||||
|
return {
|
||||||
|
export: 'text',
|
||||||
|
import: 'text',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
console.log(' render() called')
|
this.wrapper = document.createElement('div')
|
||||||
const container = document.createElement('div')
|
this.wrapper.classList.add('cdx-block')
|
||||||
container.contentEditable = true // Make the div editable like a textarea
|
this.wrapper.classList.add('ce-paragraph')
|
||||||
container.classList.add('markdown-parser')
|
this.wrapper.innerHTML = this.text
|
||||||
container.textContent = this.text
|
|
||||||
|
|
||||||
container.addEventListener('blur', () => {
|
if (!this.readOnly) {
|
||||||
this.text = container.textContent.trim()
|
this.wrapper.contentEditable = true
|
||||||
this.parseMarkdown()
|
this.wrapper.innerHTML = this.text
|
||||||
})
|
|
||||||
|
|
||||||
this.textArea = container
|
this.wrapper.addEventListener('keydown', (event) => {
|
||||||
return container
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToHeader(event, value) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {
|
||||||
|
let level = value.length
|
||||||
|
event.target.textContent = ''
|
||||||
|
this.convertBlock('header', {
|
||||||
|
level: level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (previousLine && previousLine.startsWith('1. ')) {
|
||||||
|
this.convertBlock('list', {
|
||||||
|
style: 'ordered',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
content: previousLine.replace('1. ', ''),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} else if (previousLine && this.canBeEmbed(previousLine)) {
|
||||||
|
this.wrapper.textContent = ''
|
||||||
|
this.convertBlock('embed', {
|
||||||
|
source: previousLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
save(blockContent) {
|
||||||
return {
|
return {
|
||||||
text: this.text,
|
text: blockContent.innerHTML,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
hasImage(line) {
|
||||||
* Parse Markdown text and render Editor.js blocks.
|
return /!\[.+?\]\(.+?\)/.test(line)
|
||||||
*/
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} 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 (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 } }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.api.blocks.render({ blocks })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the line matches the image syntax.
|
|
||||||
* @param {string} line - The line of text.
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
isImage(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) {
|
extractImage(line) {
|
||||||
const match = line.match(/^!\[(.*)\]\((.*)\)$/)
|
const match = line.match(/!\[(.+?)\]\((.+?)\)/)
|
||||||
return { alt: match[1], url: match[2] }
|
if (match) {
|
||||||
|
return { alt: match[1], url: match[2] }
|
||||||
|
}
|
||||||
|
return { alt: '', url: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
hasLink(line) {
|
||||||
* Check if the line matches the link syntax.
|
return /\[.+?\]\(.+?\)/.test(line)
|
||||||
* @param {string} line - The line of text.
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
isLink(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) {
|
extractLink(line) {
|
||||||
const match = line.match(/^\[(.*)\]\((.*)\)$/)
|
const match = line.match(/\[(.+?)\]\((.+?)\)/)
|
||||||
return { text: match[1], url: match[2] }
|
if (match) {
|
||||||
|
return { text: match[1], url: match[2] }
|
||||||
|
}
|
||||||
|
return { text: '', url: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
canBeEmbed(line) {
|
||||||
|
return /^https?:\/\/.+/.test(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user