diff --git a/judge/markdown.py b/judge/markdown.py index 9fd81aa..f5295ed 100644 --- a/judge/markdown.py +++ b/judge/markdown.py @@ -4,6 +4,8 @@ from django.utils.html import escape from bs4 import BeautifulSoup from pymdownx import superfences +from judge.markdown_extensions import YouTubeExtension, EmoticonExtension + EXTENSIONS = [ "pymdownx.arithmatex", @@ -22,6 +24,8 @@ EXTENSIONS = [ "markdown.extensions.admonition", "nl2br", "mdx_breakless_lists", + YouTubeExtension(), + EmoticonExtension(), ] EXTENSION_CONFIGS = { diff --git a/judge/markdown_extensions/__init__.py b/judge/markdown_extensions/__init__.py new file mode 100644 index 0000000..bc405f3 --- /dev/null +++ b/judge/markdown_extensions/__init__.py @@ -0,0 +1,2 @@ +from .youtube import YouTubeExtension +from .emoticon import EmoticonExtension diff --git a/judge/markdown_extensions/emoticon.py b/judge/markdown_extensions/emoticon.py new file mode 100644 index 0000000..4ea8e18 --- /dev/null +++ b/judge/markdown_extensions/emoticon.py @@ -0,0 +1,111 @@ +import markdown +from markdown.extensions import Extension +from markdown.inlinepatterns import InlineProcessor +import xml.etree.ElementTree as etree +import re + +EMOTICON_EMOJI_MAP = { + ":D": "\U0001F603", # Smiling Face with Open Mouth + ":)": "\U0001F642", # Slightly Smiling Face + ":-)": "\U0001F642", # Slightly Smiling Face with Nose + ":(": "\U0001F641", # Slightly Frowning Face + ":-(": "\U0001F641", # Slightly Frowning Face with Nose + ";)": "\U0001F609", # Winking Face + ";-)": "\U0001F609", # Winking Face with Nose + ":P": "\U0001F61B", # Face with Tongue + ":-P": "\U0001F61B", # Face with Tongue and Nose + ":p": "\U0001F61B", # Face with Tongue + ":-p": "\U0001F61B", # Face with Tongue and Nose + ";P": "\U0001F61C", # Winking Face with Tongue + ";-P": "\U0001F61C", # Winking Face with Tongue and Nose + ";p": "\U0001F61C", # Winking Face with Tongue + ";-p": "\U0001F61C", # Winking Face with Tongue and Nose + ":'(": "\U0001F622", # Crying Face + ":o": "\U0001F62E", # Face with Open Mouth + ":-o": "\U0001F62E", # Face with Open Mouth and Nose + ":O": "\U0001F62E", # Face with Open Mouth + ":-O": "\U0001F62E", # Face with Open Mouth and Nose + ":-0": "\U0001F62E", # Face with Open Mouth and Nose + ">:(": "\U0001F620", # Angry Face + ">:-(": "\U0001F620", # Angry Face with Nose + ">:)": "\U0001F608", # Smiling Face with Horns + ">:-)": "\U0001F608", # Smiling Face with Horns and Nose + "XD": "\U0001F606", # Grinning Squinting Face + "xD": "\U0001F606", # Grinning Squinting Face + "B)": "\U0001F60E", # Smiling Face with Sunglasses + "B-)": "\U0001F60E", # Smiling Face with Sunglasses and Nose + "O:)": "\U0001F607", # Smiling Face with Halo + "O:-)": "\U0001F607", # Smiling Face with Halo and Nose + "0:)": "\U0001F607", # Smiling Face with Halo + "0:-)": "\U0001F607", # Smiling Face with Halo and Nose + ">:P": "\U0001F92A", # Zany Face (sticking out tongue and winking) + ">:-P": "\U0001F92A", # Zany Face with Nose + ">:p": "\U0001F92A", # Zany Face (sticking out tongue and winking) + ">:-p": "\U0001F92A", # Zany Face with Nose + ":/": "\U0001F615", # Confused Face + ":-/": "\U0001F615", # Confused Face with Nose + ":\\": "\U0001F615", # Confused Face + ":-\\": "\U0001F615", # Confused Face with Nose + "3:)": "\U0001F608", # Smiling Face with Horns + "3:-)": "\U0001F608", # Smiling Face with Horns and Nose + "<3": "\u2764\uFE0F", # Red Heart + ":P": "\U0001F61D", # Face with Stuck-Out Tongue and Tightly-Closed Eyes + ":-/": "\U0001F615", # Confused Face + ":/": "\U0001F615", + ":\\": "\U0001F615", + ":-\\": "\U0001F615", + ":|": "\U0001F610", # Neutral Face + ":-|": "\U0001F610", + "8)": "\U0001F60E", # Smiling Face with Sunglasses + "8-)": "\U0001F60E", + "O:)": "\U0001F607", # Smiling Face with Halo + "O:-)": "\U0001F607", + ":3": "\U0001F60A", # Smiling Face with Smiling Eyes + "^.^": "\U0001F60A", + "-_-": "\U0001F611", # Expressionless Face + "T_T": "\U0001F62D", # Loudly Crying Face + "T.T": "\U0001F62D", + ">.<": "\U0001F623", # Persevering Face + "x_x": "\U0001F635", # Dizzy Face + "X_X": "\U0001F635", + ":]": "\U0001F600", # Grinning Face + ":[": "\U0001F641", # Slightly Frowning Face + "=]": "\U0001F600", + "=[": "\U0001F641", + "D:<": "\U0001F621", # Pouting Face + "D:": "\U0001F629", # Weary Face + "D=": "\U0001F6AB", # No Entry Sign (sometimes used to denote dismay or frustration) + ":'D": "\U0001F602", # Face with Tears of Joy + "D':": "\U0001F625", # Disappointed but Relieved Face + "D8": "\U0001F631", # Face Screaming in Fear + "-.-": "\U0001F644", # Face with Rolling Eyes + "-_-;": "\U0001F612", # Unamused +} + + +class EmoticonEmojiInlineProcessor(InlineProcessor): + def handleMatch(self, m, data): + emoticon = m.group(1) + emoji = EMOTICON_EMOJI_MAP.get(emoticon, "") + if emoji: + el = etree.Element("span") + el.text = markdown.util.AtomicString(emoji) + el.set("class", "big-emoji") + return el, m.start(0), m.end(0) + else: + return None, m.start(0), m.end(0) + + +class EmoticonExtension(Extension): + def extendMarkdown(self, md): + # Create the regex pattern to match any emoticon in the map + emoticon_pattern = ( + r"(" + "|".join(map(re.escape, EMOTICON_EMOJI_MAP.keys())) + r")" + ) + emoticon_processor = EmoticonEmojiInlineProcessor(emoticon_pattern, md) + md.inlinePatterns.register(emoticon_processor, "emoticon_to_emoji", 1) diff --git a/judge/markdown_extensions/youtube.py b/judge/markdown_extensions/youtube.py new file mode 100644 index 0000000..89a9fa8 --- /dev/null +++ b/judge/markdown_extensions/youtube.py @@ -0,0 +1,36 @@ +import markdown +from markdown.inlinepatterns import InlineProcessor +from markdown.extensions import Extension +import xml.etree.ElementTree as etree + +YOUTUBE_REGEX = ( + r"(https?://)?(www\.)?" "(youtube\.com/watch\?v=|youtu\.be/)" "([\w-]+)(&[\w=]*)?" +) + + +class YouTubeEmbedProcessor(InlineProcessor): + def handleMatch(self, m, data): + youtube_id = m.group(4) + if not youtube_id: + return None, None, None + + # Create an iframe element with the YouTube embed URL + iframe = etree.Element("iframe") + iframe.set("width", "100%") + iframe.set("height", "360") + iframe.set("src", f"https://www.youtube.com/embed/{youtube_id}") + iframe.set("frameborder", "0") + iframe.set("allowfullscreen", "true") + center = etree.Element("center") + center.append(iframe) + + # Return the iframe as the element to replace the match, along with the start and end indices + return center, m.start(0), m.end(0) + + +class YouTubeExtension(Extension): + def extendMarkdown(self, md): + # Create the YouTube link pattern + YOUTUBE_PATTERN = YouTubeEmbedProcessor(YOUTUBE_REGEX, md) + # Register the pattern to apply the YouTubeEmbedProcessor + md.inlinePatterns.register(YOUTUBE_PATTERN, "youtube", 175) diff --git a/resources/base.scss b/resources/base.scss index 49235f1..9485813 100644 --- a/resources/base.scss +++ b/resources/base.scss @@ -726,7 +726,7 @@ noscript #noscript { } .featherlight { - z-index: 1000 !important; + z-index: 1001 !important; } // @media (max-width: 500px) { diff --git a/resources/chatbox.scss b/resources/chatbox.scss index f45244b..6c4f671 100644 --- a/resources/chatbox.scss +++ b/resources/chatbox.scss @@ -27,7 +27,7 @@ margin-top: 1em; } .big-emoji { - font-size: 16px; + font-size: 1.2em; } #chat-online { border-right: 1px solid #ccc; diff --git a/resources/common.js b/resources/common.js index 8584905..ed663f1 100644 --- a/resources/common.js +++ b/resources/common.js @@ -311,17 +311,49 @@ function populateCopyButton() { }); } -function register_markdown_editors() { - if (!("Markdown" in window)) { - return; - } - $('textarea.wmd-input').each(function() { - let id = this.id.substr(9); // remove prefix "wmd-input" - var $buttonBar = $(this).prevAll('div[id^="wmd-button-bar"]').first(); - if (!$buttonBar.length || !$buttonBar.html().trim()) { - let converter = new Markdown.Converter(); - let editor = new Markdown.Editor(converter, id); - editor.run(); +function register_copy_clipboard($elements, callback) { + $elements.on('paste', function(event) { + const items = (event.clipboardData || event.originalEvent.clipboardData).items; + for (const index in items) { + const item = items[index]; + if (item.kind === 'file' && item.type.indexOf('image') !== -1) { + const blob = item.getAsFile(); + const formData = new FormData(); + formData.append('image', blob); + + $(this).prop('disabled', true); + + $.ajax({ + url: '/pagedown/image-upload/', + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(data) { + // Assuming the server returns the URL of the image + const imageUrl = data.url; + const editor = $(event.target); // Get the textarea where the event was triggered + let currentMarkdown = editor.val(); + const markdownImageText = '![](' + imageUrl + ')'; // Markdown for an image + + if (currentMarkdown) currentMarkdown += "\n"; + currentMarkdown += markdownImageText; + + editor.val(currentMarkdown); + callback?.(); + }, + error: function() { + alert('There was an error uploading the image.'); + }, + complete: () => { + // Re-enable the editor + $(this).prop('disabled', false); + } + }); + + // We only handle the first image in the clipboard data + break; + } } }); } @@ -399,9 +431,7 @@ function onWindowReady() { } }); - setTimeout(() => { - register_markdown_editors(); - }, 100); + register_copy_clipboard($('textarea.wmd-input')); $('form').submit(function (evt) { // Prevent multiple submissions of forms, see #565 diff --git a/resources/markdown.css b/resources/markdown.css index 06d79ec..8905f43 100644 --- a/resources/markdown.css +++ b/resources/markdown.css @@ -4129,17 +4129,20 @@ html .md-typeset .footnote-ref{ .md-typeset :-webkit-any(.emojione,.twemoji,.gemoji){ display:inline-flex; height:1.125em; - vertical-align:text-top + vertical-align:text-top; + font-size: 1.2em; } .md-typeset :-moz-any(.emojione,.twemoji,.gemoji){ display:inline-flex; height:1.125em; - vertical-align:text-top + vertical-align:text-top; + font-size: 1.2em; } .md-typeset :is(.emojione,.twemoji,.gemoji){ display:inline-flex; height:1.125em; - vertical-align:text-top + vertical-align:text-top; + font-size: 1.2em; } .md-typeset :-webkit-any(.emojione,.twemoji,.gemoji) svg{ fill:currentcolor; @@ -4149,12 +4152,14 @@ html .md-typeset .footnote-ref{ .md-typeset :-moz-any(.emojione,.twemoji,.gemoji) svg{ fill:currentcolor; max-height:100%; - width:1.125em + width:1.125em; + font-size: 1.2em; } .md-typeset :is(.emojione,.twemoji,.gemoji) svg{ fill:currentcolor; max-height:100%; - width:1.125em + width:1.125em; + font-size: 1.2em; } .highlight :-webkit-any(.o,.ow){ color:var(--md-code-hl-operator-color) diff --git a/resources/pagedown_init.js b/resources/pagedown_init.js new file mode 100644 index 0000000..249921e --- /dev/null +++ b/resources/pagedown_init.js @@ -0,0 +1,158 @@ +var DjangoPagedown = DjangoPagedown | {}; + +DjangoPagedown = (function() { + var converter = Markdown.getSanitizingConverter(); + var editors = {}; + var elements; + + Markdown.Extra.init(converter, { + extensions: "all" + }); + + var createEditor = function(element) { + var input = element.getElementsByClassName("wmd-input")[0]; + if (input === undefined) { + return + } + var id = input.id.substr(9); + if (!editors.hasOwnProperty(id)) { + var editor = new Markdown.Editor(converter, id, {}); + + // Handle image upload + if (element.classList.contains("image-upload-enabled")) { + var upload = element.getElementsByClassName("pagedown-image-upload")[0]; + var url = upload.getElementsByClassName("url-input")[0]; + var file = upload.getElementsByClassName("file-input")[0]; + var cancel = upload.getElementsByClassName("deletelink")[0]; + var submit = upload.getElementsByClassName("submit-input")[0]; + var loading = upload.getElementsByClassName("submit-loading")[0]; + + var close = function(value, callback = undefined) { + upload.classList.remove("show"); + url.value = ""; + file.value = ""; + document.removeEventListener('click', outsideClickListener); + if (callback) callback(value); + }; + + var outsideClickListener = function(event) { + if (!upload.contains(event.target) && upload.classList.contains("show")) { + cancel.click(); + } + }; + + editor.hooks.set("insertImageDialog", function(callback) { + upload.classList.add("show"); + + setTimeout(function() { + document.addEventListener('click', outsideClickListener); + }, 0); + + cancel.addEventListener( + "click", + function(event) { + close(null, callback); + event.preventDefault(); + }, + { once: true } + ); + + submit.addEventListener( + "click", + function() { + // Regular URL + if (url.value.length > 0) { + close(url.value, callback); + } + // File upload + else if (file.files.length > 0) { + loading.classList.add("show"); + submit.classList.remove("show"); + + var data = new FormData(); + var xhr = new XMLHttpRequest(); + data.append("image", file.files[0]); + xhr.open("POST", file.dataset.action, true); + xhr.addEventListener( + "load", + function() { + loading.classList.remove("show"); + submit.classList.add("show"); + + if (xhr.status !== 200) { + alert(xhr.statusText); + } else { + var response = JSON.parse(xhr.response); + if (response.success) { + close(response.url, callback); + } else { + if (response.error) { + var error = ""; + for (var key in response.error) { + if (response.error.hasOwnProperty(key)) { + error += key + ":" + response.error[key]; + } + } + alert(error); + } + close(null, callback); + } + } + }, + { + once: true + } + ); + xhr.send(data); + } else { + // Nothing + close(null, callback); + } + event.preventDefault(); + }, + { once: true } + ); + + return true; + }); + } + + editor.run(); + editors[id] = editor; + } + }; + + var destroyEditor = function(element) { + var input = element.getElementsByClassName("wmd-input")[0]; + if (input === undefined) { + return + } + var id = input.id.substr(9); + if (editors.hasOwnProperty(id)) { + delete editors[id]; + return true; + } + return false; + }; + + var init = function() { + elements = document.getElementsByClassName("wmd-wrapper"); + for (var i = 0; i < elements.length; ++i) { + createEditor(elements[i]); + } + }; + + return { + init: function() { + return init(); + }, + createEditor: function(element) { + return createEditor(element); + }, + destroyEditor: function(element) { + return destroyEditor(element); + } + }; +})(); + +window.onload = DjangoPagedown.init; \ No newline at end of file diff --git a/templates/chat/chat_js.html b/templates/chat/chat_js.html index 780f115..9d84526 100644 --- a/templates/chat/chat_js.html +++ b/templates/chat/chat_js.html @@ -585,5 +585,8 @@ }); $('#submit-button').on('click', submit_chat); + register_copy_clipboard($("#chat-input"), () => { + $('#chat-input').trigger('input'); + }); }); \ No newline at end of file diff --git a/templates/comments/media-js.html b/templates/comments/media-js.html index a862607..7b26ecd 100644 --- a/templates/comments/media-js.html +++ b/templates/comments/media-js.html @@ -211,14 +211,15 @@ $comments.find('a.edit-link').featherlight({ afterOpen: function () { register_dmmd_preview($('#id-edit-comment-body-preview')); - register_markdown_editors(); if ('DjangoPagedown' in window) { + DjangoPagedown.createEditor( + $('#wmd-input-id-edit-comment-body').closest('.wmd-wrapper')[0] + ); + register_copy_clipboard($('#wmd-input-id-edit-comment-body')); var $wmd = $('.featherlight .wmd-wrapper'); if ($wmd.length) { - if ('MathJax' in window) { - var preview = $('.featherlight div.wmd-preview')[0]; - renderKatex(preview); - } + var preview = $('.featherlight div.wmd-preview')[0]; + renderKatex(preview); } } $('#comment-edit').submit(function (event) { @@ -242,6 +243,11 @@ }); }); }, + beforeClose: function() { + DjangoPagedown.destroyEditor( + $('#wmd-input-id-edit-comment-body').closest('.wmd-wrapper')[0] + ); + }, variant: 'featherlight-edit' }); diff --git a/templates/markdown_editor/markdown_editor.html b/templates/markdown_editor/markdown_editor.html index f127eaa..8b7fbb9 100644 --- a/templates/markdown_editor/markdown_editor.html +++ b/templates/markdown_editor/markdown_editor.html @@ -64,7 +64,6 @@ populateCopyButton(); }, error: function(error) { - alert(error); console.log(error.message); } })