diff --git a/.eslintrc.json b/.eslintrc.json index 469f158a13..9f6a6aeea0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -303,6 +303,7 @@ "settings_ui": false, "settings_user_groups": false, "settings_users": false, + "spoilers": false, "starred_messages": false, "stream_color": false, "stream_create": false, diff --git a/frontend_tests/node_tests/rendered_markdown.js b/frontend_tests/node_tests/rendered_markdown.js index 6b17c3d900..21caa616fb 100644 --- a/frontend_tests/node_tests/rendered_markdown.js +++ b/frontend_tests/node_tests/rendered_markdown.js @@ -70,6 +70,7 @@ const get_content_element = () => { $content.set_find_results('a.stream-topic', $array([])); $content.set_find_results('span.timestamp', $array([])); $content.set_find_results('.emoji', $array([])); + $content.set_find_results('div.spoiler-header', $array([])); return $content; }; @@ -197,4 +198,34 @@ run_test('emoji', () => { rm.update_elements($content); assert(called); + + // Set page paramaters back so that test run order is independent + page_params.emojiset = 'apple'; +}); + +run_test('spoiler-header', () => { + // Setup + const $content = get_content_element(); + const $header = $.create('div.spoiler-header'); + $content.set_find_results('div.spoiler-header', $array([$header])); + + // Test that button gets appened to a spoiler header + const label = 'My Spoiler Header'; + const toggle_button_html = ''; + $header.html(label); + rm.update_elements($content); + assert.equal(toggle_button_html + label, $header.html()); +}); + +run_test('spoiler-header-empty-fill', () => { + // Setup + const $content = get_content_element(); + const $header = $.create('div.spoiler-header'); + $content.set_find_results('div.spoiler-header', $array([$header])); + + // Test that an empty header gets the default text applied (through i18n filter) + const toggle_button_html = ''; + $header.html(''); + rm.update_elements($content); + assert.equal(toggle_button_html + '
translated: Spoiler
', $header.html()); }); diff --git a/frontend_tests/node_tests/ui_init.js b/frontend_tests/node_tests/ui_init.js index e502337944..2f45f5b58e 100644 --- a/frontend_tests/node_tests/ui_init.js +++ b/frontend_tests/node_tests/ui_init.js @@ -84,6 +84,7 @@ zrequire('color_data'); zrequire('stream_data'); zrequire('muting'); zrequire('condense'); +zrequire('spoilers'); zrequire('lightbox'); zrequire('overlays'); zrequire('invite'); diff --git a/frontend_tests/zjsunit/zjquery.js b/frontend_tests/zjsunit/zjquery.js index 4889c30be7..e48f2cabca 100644 --- a/frontend_tests/zjsunit/zjquery.js +++ b/frontend_tests/zjsunit/zjquery.js @@ -144,6 +144,10 @@ exports.make_new_elem = function (selector, opts) { classes.set(class_name, true); return self; }, + append: function (arg) { + html = html + arg; + return self; + }, attr: function (name, val) { if (val === undefined) { return attrs.get(name); @@ -284,6 +288,10 @@ exports.make_new_elem = function (selector, opts) { parents_selector + ' in ' + selector); return result; }, + prepend: function (arg) { + html = arg + html; + return self; + }, prop: function (name, val) { if (val === undefined) { return properties.get(name); diff --git a/static/images/help/spoiler-collapsed.png b/static/images/help/spoiler-collapsed.png new file mode 100644 index 0000000000..0de87b1b6d Binary files /dev/null and b/static/images/help/spoiler-collapsed.png differ diff --git a/static/images/help/spoiler-expanded.png b/static/images/help/spoiler-expanded.png new file mode 100644 index 0000000000..91c446a487 Binary files /dev/null and b/static/images/help/spoiler-expanded.png differ diff --git a/static/js/bundles/app.js b/static/js/bundles/app.js index a1c5795b6e..96c0d8d51f 100644 --- a/static/js/bundles/app.js +++ b/static/js/bundles/app.js @@ -199,6 +199,7 @@ import "../settings_ui.js"; import "../search_pill.js"; import "../search_pill_widget.js"; import "../stream_ui_updates.js"; +import "../spoilers.js"; // Import Styles diff --git a/static/js/click_handlers.js b/static/js/click_handlers.js index 641dfd7f06..e902ea54be 100644 --- a/static/js/click_handlers.js +++ b/static/js/click_handlers.js @@ -11,7 +11,7 @@ exports.initialize = function () { function is_clickable_message_element(target) { return target.is("a") || target.is("img.message_inline_image") || target.is("img.twitter-avatar") || target.is("div.message_length_controller") || target.is("textarea") || target.is("input") || - target.is("i.edit_content_button") || + target.is("i.edit_content_button") || target.is(".spoiler-arrow") || target.is(".highlight") && target.parent().is("a"); } diff --git a/static/js/fenced_code.js b/static/js/fenced_code.js index dedb439179..e70b214f8b 100644 --- a/static/js/fenced_code.js +++ b/static/js/fenced_code.js @@ -5,14 +5,20 @@ // auto-completing code blocks missing a trailing close. // See backend fenced_code.py:71 for associated regexp -const fencestr = "^(~{3,}|`{3,})" + // Opening Fence +const fencestr = "^(~{3,}|`{3,})" + // Opening Fence "[ ]*" + // Spaces "(" + "\\{?\\.?" + "([a-zA-Z0-9_+-./#]*)" + // Language "\\}?" + + ")" + "[ ]*" + // Spaces - ")$"; + "(" + + "\\{?\\.?" + + "([^~`]*)" + // Header (see fenced_code.py) + "\\}?" + + ")" + + "$"; const fence_re = new RegExp(fencestr); // Default stashing function does nothing @@ -52,6 +58,20 @@ function wrap_tex(tex) { } } +function wrap_spoiler(header, text, stash_func) { + const output = []; + const header_div_open_html = '${i18n.t('Spoiler')}
`); + } + + // Add the expand/collapse button to spoiler blocks + const toggle_button_html = ''; + $(this).prepend(toggle_button_html); + }); + + // Display emoji (including realm emoji) as text if // page_params.emojiset is 'text'. if (page_params.emojiset === 'text') { diff --git a/static/js/spoilers.js b/static/js/spoilers.js new file mode 100644 index 0000000000..8a51a8c4ce --- /dev/null +++ b/static/js/spoilers.js @@ -0,0 +1,66 @@ +function collapse_spoiler(spoiler) { + const spoiler_height = spoiler.prop('scrollHeight'); + + // Set height to rendered height on next frame, then to zero on following + // frame to allow CSS transition animation to work + requestAnimationFrame(function () { + spoiler.height(spoiler_height + 'px'); + spoiler.removeClass("spoiler-content-open"); + + requestAnimationFrame(function () { + spoiler.height("0px"); + }); + }); +} + +function expand_spoiler(spoiler) { + // Normally, the height of the spoiler block is not defined absolutely on + // the `spoiler-content-open` class, but just set to `auto` (i.e. the height + // of the content). CSS animations do not work with properties set to + // `auto`, so we get the actual height of the content here and temporarily + // put it explicitly on the element styling to allow the transition to work. + const spoiler_height = spoiler.prop('scrollHeight'); + spoiler.height(spoiler_height + "px"); + // The `spoiler-content-open` class has CSS animations defined on it which + // will trigger on the frame after this class change. + spoiler.addClass("spoiler-content-open"); + + spoiler.on('transitionend', function () { + spoiler.off('transitionend'); + // When the CSS transition is over, reset the height to auto + // This keeps things working if, e.g., the viewport is resized + spoiler.height(""); + }); +} + +exports.initialize = function () { + $("body").on("click", ".spoiler-button", function (e) { + e.preventDefault(); + e.stopPropagation(); + + const arrow = $(this).children('.spoiler-arrow'); + const spoiler_content = $(this).parent().siblings(".spoiler-content"); + + if (spoiler_content.hasClass("spoiler-content-open")) { + // Content was open, we are collapsing + arrow.removeClass("spoiler-button-open"); + + // Modify ARIA roles for screen readers + $(this).attr("aria-expanded", "false"); + spoiler_content.attr("aria-hidden", "true"); + + collapse_spoiler(spoiler_content); + } else { + // Content was closed, we are expanding + arrow.addClass("spoiler-button-open"); + + // Modify ARIA roles for screen readers + $(this).attr("aria-expanded", "true"); + spoiler_content.attr("aria-hidden", "false"); + + expand_spoiler(spoiler_content); + } + }); +}; + +window.spoilers = exports; diff --git a/static/js/ui_init.js b/static/js/ui_init.js index c40a723ff8..95e531b22d 100644 --- a/static/js/ui_init.js +++ b/static/js/ui_init.js @@ -438,6 +438,7 @@ exports.initialize_everything = function () { subs.initialize(); stream_list.initialize(); condense.initialize(); + spoilers.initialize(); lightbox.initialize(); click_handlers.initialize(); copy_and_paste.initialize(); diff --git a/static/styles/rendered_markdown.scss b/static/styles/rendered_markdown.scss index 577711954e..a1ef8eb2dc 100644 --- a/static/styles/rendered_markdown.scss +++ b/static/styles/rendered_markdown.scss @@ -163,6 +163,89 @@ color: hsl(0, 0%, 50%); } + /* Spoiler styling */ + .spoiler-block { + border: hsl(0, 0%, 50%) 1px solid; + padding: 2px 8px 2px 10px; + border-radius: 10px; + position: relative; + top: 1px; + display: block; + margin: 5px 0 15px 0; + + .spoiler-header { + padding: 5px; + font-weight: bold; + } + + .spoiler-content { + overflow: hidden; + border-top: hsl(0, 0%, 50%) 0px solid; + transition: height 0.4s ease-in-out, border-top 0.4s step-end, padding 0.4s step-end; + padding: 0px; + height: 0px; + + &.spoiler-content-open { + border-top: hsl(0, 0%, 50%) 1px solid; + transition: height 0.4s ease-in-out, border-top 0.4s step-start, padding 0.4s step-start; + padding: 5px; + height: auto; + } + } + + .spoiler-button { + float: right; + width: 25px; + height: 25px; + &:hover .spoiler-arrow { + &::before, + &::after { + background-color: hsl(0, 0%, 50%); + } + } + } + + + .spoiler-arrow { + float: right; + width: 13px; + height: 13px; + position: relative; + bottom: -5px; + left: -10px; + cursor: pointer; + transition: 0.4s ease; + margin-top: 2px; + text-align: left; + transform: rotate(45deg); + &::before, + &::after { + position: absolute; + content: ''; + display: inline-block; + width: 12px; + height: 3px; + background-color: hsl(0, 0%, 83%); + transition: 0.4s ease; + } + &::after { + position: absolute; + transform: rotate(90deg); + top: -5px; + left: 5px; + } + &.spoiler-button-open { + transform: rotate(45deg) translate(-5px, -5px); + &::before { + transform: translate(10px, 0); + } + &::after { + transform: rotate(90deg) translate(10px, 0); + } + } + } + } + /* CSS for message content widgets */ table.tictactoe { width: 80px; diff --git a/templates/zerver/app/markdown_help.html b/templates/zerver/app/markdown_help.html index b161c192be..c2d1ba8998 100644 --- a/templates/zerver/app/markdown_help.html +++ b/templates/zerver/app/markdown_help.html @@ -128,6 +128,22 @@ Quoted block ```Quoted block
A
\nB
\nC
\nD
\nordinary paragraph
AA
\nBB
\nA
\nB
\nC
\nD
\nordinary paragraph
AA
\nBB
\nheader
\ncontent
\noutside spoiler
" + }, + { + "name": "spoilers_empty_header", + "input": "```spoiler\ncontent\n```\noutside spoiler\n", + "expected_output": "content
\noutside spoiler
" + }, + { + "name": "spoilers_script_tags", + "input": "```spoiler \n\n```", + "expected_output": "<script>alert(1)</script>
\n<script>alert(1)</script>
\n<script>alert(1)</script>\n\n
\n<script>alert(1)</script>\n\n
\n\n\n\n\n\n\nheader
\n\n\ncontent
\noutside spoiler
\n
outside quote
" + }, + { + "name": "spoilers_with_header_markdown", + "input": "```spoiler [Header](https://example.com) :smile:\ncontent\n```", + "expected_output": "Header :smile:
\ncontent
\nheader
\nContent http://example.com/image.png
\nheader
\nContent http://example.com/image.png
\n