Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gsoc'24): markdown syntax extension for user tagging, circuit and video embedding #27

Open
wants to merge 35 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
734e908
added the profanity, hate, violance, sex filter using `language_filte…
Waishnav Jun 17, 2024
08b32d0
fix: moderator can delete thread
Waishnav Jun 23, 2024
1d002a5
added js for dropdown, and for markdown editor in future
Waishnav Jun 23, 2024
4d48d4d
ui: dropdown for delete, edit, mark as solution actions
Waishnav Jun 23, 2024
24b22b1
feat: user can now report the forum post as spam
Waishnav Jun 25, 2024
155f8d4
feat: moderator can review and delete reported posts
Waishnav Jun 25, 2024
0391c97
fix standardrb formatting issue
Waishnav Jul 25, 2024
187c67f
fix: renaming report_spam button to report_post
Waishnav Jul 27, 2024
59f4315
fix: remove js bundeling
Waishnav Jul 28, 2024
b926dd4
fix: ci fail due to old schema in test dummy
Waishnav Jul 28, 2024
cb339c5
fix: ci fail due to migration version
Waishnav Jul 28, 2024
ecf53bb
fix: ci fail due to moderator? undefined
Waishnav Jul 28, 2024
a8ed0cb
removing stimulus controller
Waishnav Jul 28, 2024
1a85df0
added the profanity, hate, violance, sex filter using `language_filte…
Waishnav Jun 17, 2024
d32eb92
added js for dropdown, and for markdown editor in future
Waishnav Jun 23, 2024
b27d1e9
feat: user can now report the forum post as spam
Waishnav Jun 25, 2024
2f669a9
feat: adding markdown editor using simplemde
Waishnav Jul 7, 2024
94dba46
fix: simplemde initialization for forum thread form
Waishnav Jul 22, 2024
b306e8f
added js for dropdown, and for markdown editor in future
Waishnav Jun 23, 2024
0d0dab9
feat: user can now report the forum post as spam
Waishnav Jun 25, 2024
982b823
feat: adding markdown editor using simplemde
Waishnav Jul 7, 2024
66cf83f
feat: embedding circuit in forum posts
Waishnav Jul 21, 2024
ffed01d
feat: server and client side md parsing with feature flags
Waishnav Jul 22, 2024
5434a1b
add: build files should be pushed
Waishnav Jul 22, 2024
42d55c6
fix: empty tag dropdown for no error from stimulus controller
Waishnav Jul 22, 2024
e2ce1df
fix: modal missing
Waishnav Jul 22, 2024
d9c8170
removing build files
Waishnav Jul 28, 2024
0118bf7
fix: test fail standardrb
Waishnav Jul 28, 2024
ff24eb5
fix: redcarpet missing issue ci fail
Waishnav Jul 28, 2024
3658dd5
Merge branch 'master' into markdown-syntax-extensions
Waishnav Aug 14, 2024
324e97e
fix: ci fail due to user_path helper method doesn't exist
Waishnav Aug 14, 2024
6881aa5
fix: typo
Waishnav Aug 14, 2024
562b11e
Merge branch 'master' into markdown-syntax-extensions
Waishnav Aug 21, 2024
9275d05
fix: removed unnecesarry files
Waishnav Aug 21, 2024
0e7d081
fix: unecessary changes
Waishnav Aug 21, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/assets/stylesheets/simple_discussion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,13 @@
border: 1px solid #80808029;

.card-body {
overflow-x: auto;
margin-top: 16px;
}
.card-body iframe {
border-radius: $post-body-border-radius;
border: 2px solid #80808029;
}
}

// Formatting the listtile for user details
Expand Down Expand Up @@ -296,3 +301,25 @@
.thread-page-container {
padding: 24px;
}

.preview::before {
content: "Preview";
width: 80px;
}

p {
font-size: 18px;
}

blockquote {
border-left: 5px solid #e9ecef;
padding: 10px 20px;
margin: 10px 0;
font-size: 18px;
font-weight: 500;
color: #6c757d;
}

.CodeMirror-line span {
font-size: 18px;
}
58 changes: 57 additions & 1 deletion app/helpers/simple_discussion/forum_posts_helper.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
require "redcarpet"
class CustomRenderer < Redcarpet::Render::HTML
def initialize(circuit_embed: false, video_embed: false, user_tagging: false)
@circuit_embed = circuit_embed
@video_embed = video_embed
@user_tagging = user_tagging
super()
end

def image(url, title, alt_text)
case alt_text
when "Circuit"
if @circuit_embed
"<iframe width=\"540\" height=\"300\" src=\"#{url}\" frameborder=\"0\"></iframe><br>"
else
"<img src=\"#{url}\" alt=\"#{alt_text}\" title=\"#{title}\"><br>"
end
when "Video"
if @video_embed
video_id = url.split("v=")[1].split("&")[0]
"<iframe width=\"540\" height=\"300\" src=\"https://www.youtube.com/embed/#{video_id}\" frameborder=\"0\" allowfullscreen></iframe><br>"
else
"<img src=\"#{url}\" alt=\"#{alt_text}\" title=\"#{title}\"><br>"
end
else
# default image rendering
"<img src=\"#{url}\" alt=\"#{alt_text}\" title=\"#{title}\"><br>"
end
end

def link(link, _title, content)
if @user_tagging && link.start_with?("/users/")
uri = URI.parse(link)
uri.path =~ %r{^/users/\d+/?$}
# remove the brackets from the content
content = content.gsub(/[()]/, "")
"<a class='tag-user' target='_blank' href=\"#{link}\">#{content}</a>"
else
"<a href=\"#{link}\">#{content}</a>"
end
end
end

module SimpleDiscussion::ForumPostsHelper
def category_link(category)
link_to category.name, simple_discussion.forum_category_forum_threads_path(category),
Expand All @@ -6,7 +49,20 @@ def category_link(category)

# Override this method to provide your own content formatting like Markdown
def formatted_content(text)
simple_format(text)
options = {
hard_wrap: true,
filter_html: true,
autolink: true,
tables: true
}

renderer = CustomRenderer.new(
circuit_embed: SimpleDiscussion.markdown_circuit_embed,
video_embed: SimpleDiscussion.markdown_video_embed,
user_tagging: SimpleDiscussion.markdown_user_tagging
)
markdown = Redcarpet::Markdown.new(renderer, options)
markdown.render(text).html_safe
end

def forum_post_classes(forum_post)
Expand Down
250 changes: 249 additions & 1 deletion app/views/layouts/simple_discussion.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@

<% parent_layout("application") %>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>

<script type="module">
import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"
window.Stimulus = Application.start()
Expand All @@ -99,7 +103,6 @@
}
})


Stimulus.register("report-post", class extends Controller {
static targets = ["reportPostButton"]

Expand All @@ -117,4 +120,249 @@
})
}
})

import marked from 'https://cdn.jsdelivr.net/npm/marked@13.0.3/+esm'

Stimulus.register("simplemde", class extends Controller {
static values = {
circuitEmbed: Boolean,
videoEmbed: Boolean,
userTagging: Boolean
};

static targets = ["tagDropdown", "textarea"]

connect() {
this.tagDropdownTarget.style.display = "none"
this.initializeEditor(this.tagDropdownTarget);
this.initializeModals();
this.initializeUserTaggingDropdown();

const previewButton = document.querySelector(".preview")
previewButton.style.width = "80px"
previewButton.style.height = "34px"
}

initializeEditor(dropdown) {
const customButtons = [];

if (this.circuitEmbedValue) {
customButtons.push({
name: "embed-circuit",
action: this.openEmbedCircuitModal.bind(this),
className: "fa fa-microchip",
title: "Embed Circuit",
});
}

if (this.videoEmbedValue) {
customButtons.push({
name: "embed-video",
action: this.openEmbedVideoModal.bind(this),
className: "fa fa-film",
title: "Embed Video",
});
}

if (this.userTaggingValue) {
customButtons.push({
name: "tag-user",
action: (editor) => {
if (dropdown.querySelectorAll("button").length > 0) {
editor.codemirror.replaceSelection("@");
return;
}
const cursor = editor.codemirror.getCursor();
editor.codemirror.replaceSelection(`[@(name)](link_to_profile)`);
editor.codemirror.setCursor(cursor.line, cursor.ch + 2);
editor.codemirror.setSelection(
{ line: cursor.line, ch: cursor.ch + 3 },
{ line: cursor.line, ch: cursor.ch + 7 },
);
editor.codemirror.focus();
},
className: "fa fa-at",
title: "Tag User",
});

}

const toolbarOptions = [
{
name: "bold",
action: SimpleMDE.toggleBold,
className: "fa fa-bold",
title: "Bold",
},
{
name: "italic",
action: SimpleMDE.toggleItalic,
className: "fa fa-italic",
title: "Italic",
},
{
name: "heading",
action: SimpleMDE.toggleHeadingSmaller,
className: "fa fa-header",
title: "Heading",
},
"|",
{
name: "quote",
action: SimpleMDE.toggleBlockquote,
className: "fa fa-quote-left",
title: "Quote",
},
{
name: "unordered-list",
action: SimpleMDE.toggleUnorderedList,
className: "fa fa-list-ul",
title: "Unordered List",
},
{
name: "ordered-list",
action: SimpleMDE.toggleOrderedList,
className: "fa fa-list-ol",
title: "Ordered List",
},
"|",
{
name: "link",
action: SimpleMDE.drawLink,
className: "fa fa-link",
title: "Create Link",
},
...customButtons,
"|",
{
name: "preview",
className: "preview no-disable",
action: function(editor) {
SimpleMDE.togglePreview(editor);
},
title: "Preview",
},
];

this.editor = new SimpleMDE({
element: this.textareaTarget,
forceSync: true,
toolbar: toolbarOptions,
spellChecker: false,
previewRender: (plainText, preview) => {
let markdownText = this.customMarkdownParser(plainText);
return (preview.innerHTML = marked(markdownText));
},
});
}

customMarkdownParser(markdownText) {
let parsedText = markdownText;

// ![Circuit](link_to_circuit) => <iframe src="link_to_circuit" width="540" height="300" frameborder="0"></iframe>
if (this.circuitEmbedValue) {
const embedCircuitPattern = /!\[Circuit\]\(([^)]+)\)/g;
parsedText = parsedText.replace(embedCircuitPattern, (match, circuitURL) =>
`<iframe src="${circuitURL}" width="540" height="300" frameborder="0"></iframe><br>`
);
}

// [Video](link_to_video) => <iframe width="540" height="300" src="link_to_video" frameborder="0" allowfullscreen></iframe>
if (this.videoEmbedValue) {
const embedVideoPattern = /!\[Video\]\(([^)]+)\)/g;
parsedText = parsedText.replace(embedVideoPattern, (match, videoURL) => {
const videoId = videoURL.split("v=")[1].split("&")[0];
return `<iframe width="540" height="300" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe><br>`;
});
}

// [@(name)](link_to_profile) => <a class="tag-user" target="blank" href="link_to_profile">@name</a>
if (this.userTaggingValue) {
const tagUserPattern = /\[@\(([^)]+)\)\]\(([^)]+)\)/g;
parsedText = parsedText.replace(tagUserPattern, (match, username, profileURL) =>
`<a class="tag-user" target="_blank" href="${profileURL}">@${username}</a>`
);
}

return parsedText;
}

initializeModals() {
this.circuitModal = new bootstrap.Modal(document.getElementById('embedCircuitModal'));
document.getElementById('insertCircuitEmbed').addEventListener('click', this.insertCircuitEmbed.bind(this));

this.videoModal = new bootstrap.Modal(document.getElementById('embedVideoModal'));
document.getElementById('insertVideoEmbed').addEventListener('click', this.insertVideoEmbed.bind(this));
}

openEmbedCircuitModal() {
this.circuitModal.show();
}

openEmbedVideoModal() {
this.videoModal.show();
}

insertCircuitEmbed() {
const embedLink = document.getElementById('circuitEmbedLink').value;
if (embedLink) {
this.editor.codemirror.replaceSelection(`![Circuit](${embedLink})`);
}
this.circuitModal.hide();
}

insertVideoEmbed() {
const embedLink = document.getElementById('videoEmbedLink').value;
if (embedLink) {
this.editor.codemirror.replaceSelection(`![Video](${embedLink})`);
}
this.videoModal.hide();
}

initializeUserTaggingDropdown() {
let codemirror = this.editor.codemirror;
// Tag User Dropdown
const dropdown = this.tagDropdownTarget
this.editor.codemirror.on("change", function(cm, change) {
const cursorPos = cm.cursorCoords(true, "local")
const editorAreaPos = document.querySelector(".CodeMirror").getBoundingClientRect()
// we need to check whether dropdown have atleast one user to show it
if (change.origin === "+input" && change.text[0] === "@" && dropdown.querySelectorAll("button").length > 0) {
dropdown.style.display = "block"
dropdown.style.position = "relative"
dropdown.style.top = `${cursorPos.top - editorAreaPos.height - 50}px`
dropdown.style.left = `${cursorPos.left}px`
}
// if backspace is pressed and dropdown is visible then hide it
if (change.origin === "+delete" && change.removed[0] === "@") {
dropdown.style.display = "none"
}
// basic search as user types in the textarea after @
const searchQuery = change.text[0] === "@" ? change.text.join("").slice(1) : change.text.join("")
const dropdownButtons = dropdown.querySelectorAll("button")
dropdownButtons.forEach(button => {
if (button.dataset.name.toLowerCase().includes(searchQuery.toLowerCase())) {
button.style.display = "block"
} else {
button.style.display = "none"
}
})
});

// when clicked on dropdown buttons its value should get inserted in the textarea
const dropdownButtons = dropdown.querySelectorAll("button")
dropdownButtons.forEach(button => {
button.addEventListener("click", (event) => {
const cursor = codemirror.getCursor()
// remove the untill @ from the text
const line = codemirror.getLine(cursor.line)
const start = line.lastIndexOf("@")
codemirror.replaceRange("", { line: cursor.line, ch: start }, cursor)
codemirror.replaceRange(`[@(${event.target.dataset.name})](${event.target.dataset.profileLink})`, cursor)
dropdown.style.display = "none"
})
})

}
})
</script>
Loading