diff --git a/.flake8 b/.flake8 index be5d5515c..55650c87f 100644 --- a/.flake8 +++ b/.flake8 @@ -18,6 +18,9 @@ per-file-ignores = ./judge/management/commands/runmoss.py:F403,F405 # E501: line too long, ignore in migrations ./judge/migrations/*.py:E501 + # E303: too many blank lines + # PyCharm likes to have double lines between class/def in an if statement. + ./judge/widgets/pagedown.py:E303 exclude = # belongs to the user ./dmoj/local_settings.py, diff --git a/.gitmodules b/.gitmodules index 655fd766d..0bea66255 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ +[submodule "resources/pagedown"] + path = resources/pagedown + url = https://github.com/DMOJ/dmoj-pagedown.git + branch = master [submodule "resources/libs"] path = resources/libs url = https://github.com/DMOJ/site-assets.git diff --git a/dmoj/settings.py b/dmoj/settings.py index feecd815d..a08bbd8df 100644 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -269,6 +269,7 @@ INSTALLED_APPS += ( 'social_django', 'compressor', 'django_ace', + 'pagedown', 'sortedm2m', 'statici18n', 'impersonate', diff --git a/judge/comments.py b/judge/comments.py index 618f419c0..21481394f 100644 --- a/judge/comments.py +++ b/judge/comments.py @@ -20,7 +20,7 @@ from reversion.models import Revision, Version from judge.dblock import LockModel from judge.models import Comment, CommentLock -from judge.widgets import MartorWidget +from judge.widgets import HeavyPreviewPageDownWidget class CommentForm(ModelForm): @@ -31,7 +31,9 @@ class CommentForm(ModelForm): 'parent': forms.HiddenInput(), } - widgets['body'] = MartorWidget(attrs={'data-markdownfy-url': reverse_lazy('comment_preview')}) + if HeavyPreviewPageDownWidget is not None: + widgets['body'] = HeavyPreviewPageDownWidget(preview=reverse_lazy('comment_preview'), + preview_timeout=1000, hide_preview_button=True) def __init__(self, request, *args, **kwargs): self.request = request diff --git a/judge/forms.py b/judge/forms.py index cf9d842e8..87003e56f 100644 --- a/judge/forms.py +++ b/judge/forms.py @@ -20,7 +20,7 @@ from judge.models import Contest, Language, Organization, Problem, ProblemPoints WebAuthnCredential from judge.utils.mail import validate_email_domain from judge.utils.subscription import newsletter_id -from judge.widgets import MartorWidget, Select2MultipleWidget, Select2Widget +from judge.widgets import HeavyPreviewPageDownWidget, Select2MultipleWidget, Select2Widget TOTP_CODE_LENGTH = 6 @@ -63,7 +63,11 @@ class ProfileForm(ModelForm): fields.append('math_engine') widgets['math_engine'] = Select2Widget(attrs={'style': 'width:200px'}) - widgets['about'] = MartorWidget(attrs={'data-markdownfy-url': reverse_lazy('profile_preview')}) + if HeavyPreviewPageDownWidget is not None: + widgets['about'] = HeavyPreviewPageDownWidget( + preview=reverse_lazy('profile_preview'), + attrs={'style': 'max-width:700px;min-width:700px;width:700px'}, + ) def clean_about(self): if 'about' in self.changed_data and not self.instance.has_any_solves: @@ -169,7 +173,8 @@ class EditOrganizationForm(ModelForm): model = Organization fields = ['about', 'logo_override_image', 'admins'] widgets = {'admins': Select2MultipleWidget(attrs={'style': 'width: 200px'})} - widgets['about'] = MartorWidget(attrs={'data-markdownfy-url': reverse_lazy('organization_preview')}) + if HeavyPreviewPageDownWidget is not None: + widgets['about'] = HeavyPreviewPageDownWidget(preview=reverse_lazy('organization_preview')) class CustomAuthenticationForm(AuthenticationForm): diff --git a/judge/views/comment.py b/judge/views/comment.py index f87e981df..7b2e086a7 100644 --- a/judge/views/comment.py +++ b/judge/views/comment.py @@ -7,7 +7,6 @@ from django.forms.models import ModelForm from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, \ HttpResponseRedirect from django.shortcuts import get_object_or_404 -from django.urls import reverse_lazy from django.utils.translation import gettext as _ from django.views.decorators.http import require_POST from django.views.generic import DetailView, UpdateView @@ -17,7 +16,7 @@ from reversion.models import Version from judge.dblock import LockModel from judge.models import Comment, CommentVote from judge.utils.views import TitleMixin -from judge.widgets import MartorWidget +from judge.widgets import MathJaxPagedownWidget __all__ = ['upvote_comment', 'downvote_comment', 'CommentEditAjax', 'CommentContent', 'CommentEdit'] @@ -123,14 +122,8 @@ class CommentEditForm(ModelForm): class Meta: model = Comment fields = ['body'] - widgets = { - 'body': MartorWidget( - attrs={ - 'id': 'id_edit', - 'data-markdownfy-url': reverse_lazy('comment_preview'), - }, - ), - } + if MathJaxPagedownWidget is not None: + widgets = {'body': MathJaxPagedownWidget(attrs={'id': 'id-edit-comment-body'})} class CommentEditAjax(LoginRequiredMixin, CommentMixin, UpdateView): diff --git a/judge/views/ticket.py b/judge/views/ticket.py index 3b012339a..eec3d45b4 100644 --- a/judge/views/ticket.py +++ b/judge/views/ticket.py @@ -22,9 +22,11 @@ from judge.utils.diggpaginator import DiggPaginator from judge.utils.tickets import filter_visible_tickets, own_ticket_filter from judge.utils.views import SingleObjectFormView, TitleMixin, paginate_query_context from judge.views.problem import ProblemMixin -from judge.widgets import MartorWidget +from judge.widgets import HeavyPreviewPageDownWidget -ticket_widget = MartorWidget(attrs={'data-markdownfy-url': reverse_lazy('ticket_preview')}) +ticket_widget = (forms.Textarea() if HeavyPreviewPageDownWidget is None else + HeavyPreviewPageDownWidget(preview=reverse_lazy('ticket_preview'), + preview_timeout=1000, hide_preview_button=True)) class TicketForm(forms.Form): diff --git a/judge/views/widgets.py b/judge/views/widgets.py index e4b234ae6..f1aec4ef0 100644 --- a/judge/views/widgets.py +++ b/judge/views/widgets.py @@ -6,9 +6,9 @@ from urllib.parse import urljoin from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.files.storage import default_storage -from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, \ - HttpResponseRedirect +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect from django.views.decorators.http import require_POST +from martor.api import imgur_uploader from judge.models import Submission @@ -51,11 +51,12 @@ def django_uploader(image): @login_required def martor_image_uploader(request): - if not request.user.is_staff: - return HttpResponseNotFound() if request.method != 'POST' or not request.is_ajax() or 'markdown-image-upload' not in request.FILES: return HttpResponseBadRequest('Invalid request') image = request.FILES['markdown-image-upload'] - data = django_uploader(image) + if request.user.is_staff: + data = django_uploader(image) + else: + data = imgur_uploader(image) return HttpResponse(data, content_type='application/json') diff --git a/judge/widgets/__init__.py b/judge/widgets/__init__.py index 99099399c..ec7b776dd 100644 --- a/judge/widgets/__init__.py +++ b/judge/widgets/__init__.py @@ -1,4 +1,5 @@ from judge.widgets.checkbox import CheckboxSelectMultipleWithSelectAll from judge.widgets.martor import * from judge.widgets.mixins import CompressorWidgetMixin +from judge.widgets.pagedown import * from judge.widgets.select2 import * diff --git a/judge/widgets/martor.py b/judge/widgets/martor.py index e7911d0d5..9f8738e0b 100644 --- a/judge/widgets/martor.py +++ b/judge/widgets/martor.py @@ -12,8 +12,6 @@ class MartorWidget(OldMartorWidget): class AdminMartorWidget(OldAdminMartorWidget): - UPLOADS_ENABLED = True - class Media: css = MartorWidget.Media.css js = ['admin/js/jquery.init.js', 'martor-mathjax.js'] diff --git a/judge/widgets/pagedown.py b/judge/widgets/pagedown.py new file mode 100644 index 000000000..dc0735921 --- /dev/null +++ b/judge/widgets/pagedown.py @@ -0,0 +1,67 @@ +from django.forms.utils import flatatt +from django.template.loader import get_template +from django.utils.encoding import force_str +from django.utils.html import conditional_escape + +from judge.widgets.mixins import CompressorWidgetMixin + +__all__ = ['PagedownWidget', 'MathJaxPagedownWidget', 'HeavyPreviewPageDownWidget'] + +try: + from pagedown.widgets import PagedownWidget as OldPagedownWidget +except ImportError: + PagedownWidget = None + MathJaxPagedownWidget = None + HeavyPreviewPageDownWidget = None +else: + class PagedownWidget(CompressorWidgetMixin, OldPagedownWidget): + # The goal here is to compress all the pagedown JS into one file. + # We do not want any further compress down the chain, because + # 1. we'll create multiple large JS files to download. + # 2. this is not a problem here because all the pagedown JS files will be used together. + compress_js = True + + def __init__(self, *args, **kwargs): + kwargs.setdefault('css', ()) + super(PagedownWidget, self).__init__(*args, **kwargs) + + + class MathJaxPagedownWidget(PagedownWidget): + class Media: + js = [ + 'mathjax_config.js', + 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.0/es5/tex-chtml.min.js', + 'pagedown_math.js', + ] + + + class HeavyPreviewPageDownWidget(PagedownWidget): + def __init__(self, *args, **kwargs): + kwargs.setdefault('template', 'pagedown.html') + self.preview_url = kwargs.pop('preview') + self.preview_timeout = kwargs.pop('preview_timeout', None) + self.hide_preview_button = kwargs.pop('hide_preview_button', False) + super(HeavyPreviewPageDownWidget, self).__init__(*args, **kwargs) + + def render(self, name, value, attrs=None, renderer=None): + if value is None: + value = '' + final_attrs = self.build_attrs(attrs, {'name': name}) + if 'class' not in final_attrs: + final_attrs['class'] = '' + final_attrs['class'] += ' wmd-input' + return get_template(self.template).render(self.get_template_context(final_attrs, value)) + + def get_template_context(self, attrs, value): + return { + 'attrs': flatatt(attrs), + 'body': conditional_escape(force_str(value)), + 'id': attrs['id'], + 'show_preview': self.show_preview, + 'preview_url': self.preview_url, + 'preview_timeout': self.preview_timeout, + 'extra_classes': 'dmmd-no-button' if self.hide_preview_button else None, + } + + class Media: + js = ['dmmd-preview.js'] diff --git a/requirements.txt b/requirements.txt index 42d50c0df..db098fab3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Django>=3.2,<4 django_compressor>=3 django-mptt>=0.13 +django-pagedown<2 django-registration-redux>=2.10 django-reversion>=3.0.5,<4 django-social-share diff --git a/resources/admin/css/pagedown.css b/resources/admin/css/pagedown.css new file mode 100644 index 000000000..4aae8e45a --- /dev/null +++ b/resources/admin/css/pagedown.css @@ -0,0 +1,10 @@ +.wmd-wrapper { + padding-right: 15px !important; +} + + +.wmd-preview { + margin-top: 15px; + padding: 15px; + word-break: break-word; +} diff --git a/resources/comments.scss b/resources/comments.scss index 5e7bf67dd..85290afdb 100644 --- a/resources/comments.scss +++ b/resources/comments.scss @@ -60,10 +60,6 @@ a { box-sizing: border-box; } -.comment-edit-form { - min-width: 60em; -} - .comment-post-wrapper { padding-bottom: 5px; diff --git a/resources/dmmd-preview.js b/resources/dmmd-preview.js new file mode 100644 index 000000000..420faa988 --- /dev/null +++ b/resources/dmmd-preview.js @@ -0,0 +1,99 @@ +$(function () { + window.register_dmmd_preview = function ($preview) { + var $form = $preview.parents('form').first(); + var $update = $preview.find('.dmmd-preview-update'); + var $content = $preview.find('.dmmd-preview-content'); + var preview_url = $preview.attr('data-preview-url'); + var $textarea = $('#' + $preview.attr('data-textarea-id')); + + // Submit the form if Ctrl+Enter is pressed in pagedown textarea. + $textarea.keydown(function (ev) { + // Ctrl+Enter pressed (metaKey used to support command key on mac). + if ((ev.metaKey || ev.ctrlKey) && ev.which == 13) { + $form.submit(); + } + }); + + $update.click(function () { + var text = $textarea.val(); + if (text) { + $preview.addClass('dmmd-preview-stale'); + $.post(preview_url, { + content: text, + csrfmiddlewaretoken: $.cookie('csrftoken') + }, function (result) { + $content.html(result); + $preview.addClass('dmmd-preview-has-content').removeClass('dmmd-preview-stale'); + + var $jax = $content.find('.require-mathjax-support'); + if ($jax.length) { + if (!('MathJax' in window)) { + $.ajax({ + type: 'GET', + url: $jax.attr('data-config'), + dataType: 'script', + cache: true, + success: function () { + $.ajax({ + type: 'GET', + url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.0/es5/tex-chtml.min.js', + dataType: 'script', + cache: true, + success: function () { + MathJax.typesetPromise([$content[0]]).then(function () { + $content.find('.tex-image').hide(); + $content.find('.tex-text').show(); + }); + } + }); + } + }); + } else { + MathJax.typesetPromise([$content[0]]).then(function () { + $content.find('.tex-image').hide(); + $content.find('.tex-text').show(); + }); + } + } + }); + } else { + $content.empty(); + $preview.removeClass('dmmd-preview-has-content').removeClass('dmmd-preview-stale'); + } + }).click(); + + var timeout = $preview.attr('data-timeout'); + var last_event = null; + var last_text = $textarea.val(); + if (timeout) { + $textarea.on('keyup paste', function () { + var text = $textarea.val(); + if (last_text == text) return; + last_text = text; + + $preview.addClass('dmmd-preview-stale'); + if (last_event) + clearTimeout(last_event); + last_event = setTimeout(function () { + $update.click(); + last_event = null; + }, timeout); + }); + } + }; + + $('.dmmd-preview').each(function () { + register_dmmd_preview($(this)); + }); + + if ('django' in window && 'jQuery' in window.django) + django.jQuery(document).on('formset:added', function(event, $row) { + var $preview = $row.find('.dmmd-preview'); + if ($preview.length) { + var id = $row.attr('id'); + id = id.substr(id.lastIndexOf('-') + 1); + $preview.attr('data-textarea-id', $preview.attr('data-textarea-id').replace('__prefix__', id)); + register_dmmd_preview($preview); + } + }); +}); diff --git a/resources/dmmd-preview.scss b/resources/dmmd-preview.scss new file mode 100644 index 000000000..30285ef70 --- /dev/null +++ b/resources/dmmd-preview.scss @@ -0,0 +1,43 @@ +@import "vars"; + +div.dmmd-preview { + padding: 0; +} + +div.dmmd-preview-update { + background: $color_primary25; + color: $color_primary75; + text-align: center; + cursor: pointer; + border-radius: 4px; + height: 2em; + line-height: 2em; +} + +div.dmmd-preview-content { + padding: 0 7px; +} + +div.dmmd-preview.dmmd-preview-has-content div.dmmd-preview-update { + border-radius: 4px 4px 0 0; +} + +div.dmmd-preview-has-content div.dmmd-preview-content { + padding-bottom: 7px; +} + +div.dmmd-no-button div.dmmd-preview-update { + display: none; +} + +div.dmmd-no-button div.dmmd-preview-content { + padding-bottom: 0; +} + +div.dmmd-no-button:not(.dmmd-preview-has-content) { + display: none; +} + +div.dmmd-preview-stale { + background: repeating-linear-gradient(-45deg, $color_primary0, $color_primary0 10px, $color_primary5 10px, $color_primary5 20px); +} diff --git a/resources/martor-description.scss b/resources/martor-description.scss index 410f3035c..90d2d09dc 100644 --- a/resources/martor-description.scss +++ b/resources/martor-description.scss @@ -2,8 +2,6 @@ form .martor-preview { @include content-description; - - min-height: 400px; ul li { list-style: unset !important; @@ -23,7 +21,7 @@ form .martor-preview { } .section-martor { - div[data-tab^="editor-tab-"] { + div[data-tab="editor-tab-description"] { padding: 0; } @@ -35,12 +33,6 @@ form .martor-preview { font-size: 0.8rem; } - .martor-toolbar { - flex: 0 1 auto !important; - overflow-x: auto; - overflow-y: hidden; - } - .martor-field { height: 400px; } diff --git a/resources/misc.scss b/resources/misc.scss index ea3afb401..c80fe4a8d 100644 --- a/resources/misc.scss +++ b/resources/misc.scss @@ -35,7 +35,7 @@ } .centered-form { - max-width: 800px; + max-width: 700px; margin: auto; .submit-bar { diff --git a/resources/pagedown b/resources/pagedown new file mode 160000 index 000000000..6e5eac438 --- /dev/null +++ b/resources/pagedown @@ -0,0 +1 @@ +Subproject commit 6e5eac43883a314d1e0dbc5e70069798859eacbc diff --git a/resources/pagedown-widget.scss b/resources/pagedown-widget.scss new file mode 100644 index 000000000..8e585c3fe --- /dev/null +++ b/resources/pagedown-widget.scss @@ -0,0 +1,125 @@ +@import "vars"; + +.wmd-panel { + margin: 0; + width: 100%; + min-width: 0; +} + +.wmd-button-bar { + width: 100%; + background-color: Silver; +} + +.wmd-input { + height: 300px; + width: 100%; + max-width: 100%; + background: $color_primary0; + border: 1px solid $color_primary50; + font-family: Consolas, "Liberation Mono", Monaco, "Courier New", monospace !important; +} + +.wmd-preview { + background: none; + word-break: break-word; +} + +.wmd-button-row { + margin: 5px; + padding: 0; + line-height: 0; +} + +.wmd-spacer { + width: 15px; + height: 20px; + display: inline-block; + list-style: none; +} + +.wmd-button { + width: 20px; + height: 20px; + padding-left: 2px; + padding-right: 3px; + display: inline-block; + list-style: none; + cursor: pointer; +} + +.wmd-button > span { + @include vars-img; + background: url($path_to_root + '/pagedown/wmd-buttons.png') no-repeat 0 0; + width: 20px; + height: 20px; + display: inline-block; +} + +.wmd-prompt-background { + background-color: Black; +} + +.wmd-prompt-dialog { + border: 1px solid $color_primary25; + background-color: $color_primary5; +} + +.wmd-prompt-dialog > div { + font-size: 0.8em; + font-family: arial, helvetica, sans-serif; +} + +.wmd-prompt-dialog > form > input[type="text"] { + border: 1px solid $color_primary25; + color: $color_primary100; +} + +.wmd-prompt-dialog > form > input[type="button"] { + border: 1px solid $color_primary50; + font-family: trebuchet MS, helvetica, sans-serif; + font-size: 0.8em; + font-weight: bold; +} + +.wmd-wrapper { + padding-right: 0 !important; +} + +.wmd-preview { + margin-top: 15px; + padding: 7px; + background: $color_primary0; + line-height: 1.5em; + font-size: 1em; + border: 1px solid $color_primary50; + border-radius: 5px; + box-sizing: border-box; +} + +.wmd-preview:empty { + display: none; +} + +.wmd-preview h1, .wmd-preview h2, .wmd-preview h3, .wmd-preview h4, .wmd-preview h5, .wmd-preview h6 { + font-weight: bold !important; + margin-left: 0 !important; +} + +.wmd-preview:not(.dmmd-preview) h1 { + font-size: 1.6em !important; + margin: 0 !important; + padding: 0 !important; +} + +.wmd-preview:not(.dmmd-preview) h2 { + font-size: 1.4em !important +} + +.wmd-preview:not(.dmmd-preview) h3 { + font-size: 1em !important +} + +.wmd-preview:not(.dmmd-preview) h4, .wmd-preview:not(.dmmd-preview) h5, .wmd-preview:not(.dmmd-preview) h6 { + font-size: .9em !important +} diff --git a/resources/pagedown_math.js b/resources/pagedown_math.js new file mode 100644 index 000000000..83fa22737 --- /dev/null +++ b/resources/pagedown_math.js @@ -0,0 +1,19 @@ +function mathjax_pagedown($) { + if ('MathJax' in window) { + $.each(window.editors, function (id, editor) { + var preview = $('div.wmd-preview#' + id + '_wmd_preview')[0]; + if (preview) { + editor.hooks.chain('onPreviewRefresh', function () { + MathJax.typeset([preview]); + }); + MathJax.typeset([preview]); + } + }); + } +} + +window.mathjax_pagedown = mathjax_pagedown; + +$(window).on('load', function () { + (mathjax_pagedown)('$' in window ? $ : django.jQuery); +}); diff --git a/resources/style.scss b/resources/style.scss index 788287c22..bd206063d 100644 --- a/resources/style.scss +++ b/resources/style.scss @@ -14,6 +14,8 @@ @import "widgets"; @import "featherlight"; @import "comments"; +@import "pagedown-widget"; +@import "dmmd-preview"; @import "submission"; @import "contest"; @import "misc"; diff --git a/resources/ticket.scss b/resources/ticket.scss index 98041152c..255322ee0 100644 --- a/resources/ticket.scss +++ b/resources/ticket.scss @@ -3,7 +3,7 @@ form#ticket-form { display: block; margin: 0 auto; - max-width: 800px; + max-width: 750px; padding-top: 1em; #id_title { @@ -72,7 +72,7 @@ div.ticket-title { display: flex; flex-direction: row; flex-wrap: wrap-reverse; - max-width: 1200px; + max-width: 1000px; } .ticket-sidebar { diff --git a/resources/wpadmin/css/wpadmin.site.css b/resources/wpadmin/css/wpadmin.site.css index 0542adfbf..d2fe84e5b 100644 --- a/resources/wpadmin/css/wpadmin.site.css +++ b/resources/wpadmin/css/wpadmin.site.css @@ -6,6 +6,18 @@ line-height: unset !important; } +.wmd-wrapper { + padding-top: 2em; +} + +.wmd-wrapper ul.wmd-button-row { + margin-left: 0; +} + +.wmd-input { + width: 100% !important; +} + #content .content-description h1, #content .content-description h2, #content .content-description h3, @@ -53,3 +65,8 @@ select#id_tags.django-select2 { max-width: unset; } } + +.dmmd-preview-update { + position: sticky; + top: 38px; +} diff --git a/templates/blog/content.html b/templates/blog/content.html index 9337592a2..062e19e7c 100644 --- a/templates/blog/content.html +++ b/templates/blog/content.html @@ -48,4 +48,5 @@ {% if REQUIRE_JAX %} {% include "mathjax-load.html" %} {% endif %} + {% include "comments/math.html" %} {% endblock %} diff --git a/templates/comments/edit-ajax.html b/templates/comments/edit-ajax.html index 4c88afa9d..2bdbd1418 100644 --- a/templates/comments/edit-ajax.html +++ b/templates/comments/edit-ajax.html @@ -1,4 +1,4 @@ -