From 1cb040647b37f9f2269c2b39ae6495830f49ac4b Mon Sep 17 00:00:00 2001 From: Sara Gulotta Date: Sat, 4 Apr 2020 16:14:34 -0400 Subject: [PATCH] markdown: Add support for spoilers. This adds support for a "spoiler" syntax in Zulip's markdown, which can be used to hide content that one doesn't want to be immediately visible without a click. We use our own spoiler block syntax inspired by Zulip's existing quote and math block markdown extensions, rather than requiring a token on every line, as is present in some other markdown spoiler implementations. Fixes #5802. Co-authored-by: Dylan Nugent --- .eslintrc.json | 1 + .../node_tests/rendered_markdown.js | 31 +++++++ frontend_tests/node_tests/ui_init.js | 1 + frontend_tests/zjsunit/zjquery.js | 8 ++ static/images/help/spoiler-collapsed.png | Bin 0 -> 9784 bytes static/images/help/spoiler-expanded.png | Bin 0 -> 14445 bytes static/js/bundles/app.js | 1 + static/js/click_handlers.js | 2 +- static/js/fenced_code.js | 49 ++++++++++- static/js/rendered_markdown.js | 13 +++ static/js/spoilers.js | 66 ++++++++++++++ static/js/ui_init.js | 1 + static/styles/rendered_markdown.scss | 83 ++++++++++++++++++ templates/zerver/app/markdown_help.html | 16 ++++ .../format-your-message-using-markdown.md | 23 +++++ tools/setup/lang.json | 1 + tools/test-js-with-node | 1 + zerver/lib/bugdown/fenced_code.py | 61 ++++++++++++- .../tests/fixtures/markdown_test_cases.json | 32 +++++++ 19 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 static/images/help/spoiler-collapsed.png create mode 100644 static/images/help/spoiler-expanded.png create mode 100644 static/js/spoilers.js diff --git a/.eslintrc.json b/.eslintrc.json index 469f158a135a7..9f6a6aeea057b 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 6b17c3d90035d..21caa616fbe32 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 e50233794423c..2f45f5b58eb77 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 4889c30be73ba..e48f2cabcad6d 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 0000000000000000000000000000000000000000..0de87b1b6da8fa404ffd9e58004bea37dfe2a098 GIT binary patch literal 9784 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⁡cHfilhTWJ7ba|6AC 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 literal 0 HcmV?d00001 diff --git a/static/images/help/spoiler-expanded.png b/static/images/help/spoiler-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..91c446a487084a673b9cfe353f324b246db62bc8 GIT binary patch literal 14445 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 W#!C4h%g~$8+T>+bWQwE={r?|tHYITY literal 0 HcmV?d00001 diff --git a/static/js/bundles/app.js b/static/js/bundles/app.js index a1c5795b6e368..96c0d8d51f937 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 641dfd7f06cb2..e902ea54beb4a 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 dedb4391792f3..e70b214f8bffc 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 = '
'; + const end_header_start_content_html = '
'; + + output.push(stash_func(header_div_open_html)); + output.push(header); + output.push(stash_func(end_header_start_content_html)); + output.push(text); + output.push(stash_func(footer_html)); + return output.join("\n\n"); +} + exports.set_stash_func = function (stash_handler) { stash_func = stash_handler; }; @@ -62,7 +82,7 @@ exports.process_fenced_code = function (content) { const handler_stack = []; let consume_line; - function handler_for_fence(output_lines, fence, lang) { + function handler_for_fence(output_lines, fence, lang, header) { // lang is ignored except for 'quote', as we // don't do syntax highlighting yet return (function () { @@ -108,6 +128,26 @@ exports.process_fenced_code = function (content) { }; } + if (lang === 'spoiler') { + return { + handle_line: function (line) { + if (line === fence) { + this.done(); + } else { + lines.push(line); + } + }, + + done: function () { + const text = wrap_spoiler(header, lines.join('\n'), stash_func); + output_lines.push(''); + output_lines.push(text); + output_lines.push(''); + handler_stack.pop(); + }, + }; + } + return { handle_line: function (line) { if (line === fence) { @@ -146,7 +186,8 @@ exports.process_fenced_code = function (content) { if (match) { const fence = match[1]; const lang = match[3]; - const handler = handler_for_fence(output_lines, fence, lang); + const header = match[5]; + const handler = handler_for_fence(output_lines, fence, lang, header); handler_stack.push(handler); } else { output_lines.push(line); diff --git a/static/js/rendered_markdown.js b/static/js/rendered_markdown.js index 6085e3c7f4aa8..cfcbcb0043584 100644 --- a/static/js/rendered_markdown.js +++ b/static/js/rendered_markdown.js @@ -153,6 +153,19 @@ exports.update_elements = (content) => { } }); + content.find('div.spoiler-header').each(function () { + // If a spoiler block has no header content, it should have a default header + // We do this client side to allow for i18n by the client + if ($.trim($(this).html()).length === 0) { + $(this).append(`

${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 0000000000000..8a51a8c4ce417 --- /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 c40a723ff8bb8..95e531b22d40b 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 577711954eeb1..a1ef8eb2dca7e 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 b161c192be443..c2d1ba899877f 100644 --- a/templates/zerver/app/markdown_help.html +++ b/templates/zerver/app/markdown_help.html @@ -128,6 +128,22 @@ ```

Quoted block

+ + ```spoiler Always visible heading +This text won't be visible until the user clicks. +``` + +
+
+

Always visible heading

+
+ +
+

This text won't be visible until the user clicks.

+
+
+ + Some inline math $$ e^{i \pi } + 1 = 0 $$ diff --git a/templates/zerver/help/format-your-message-using-markdown.md b/templates/zerver/help/format-your-message-using-markdown.md index 150ba82928ff7..8ed25d6008dac 100644 --- a/templates/zerver/help/format-your-message-using-markdown.md +++ b/templates/zerver/help/format-your-message-using-markdown.md @@ -13,6 +13,7 @@ to allow you to easily format your messages. * [Code blocks](#code) * [LaTeX](#latex) * [Quotes](#quotes) +* [Spoilers](#spoilers) * [Emoji and emoticons](#emoji-and-emoticons) * [Mentions](#mentions) * [Status messages](#status-messages) @@ -150,6 +151,28 @@ quote in two paragraphs ![](/static/images/help/markdown-quotes.png) +## Spoilers + +You can use spoilers to hide content that you do not want to be visible until +the user interacts with it. + + +~~~ +Normal content in message + +```spoiler Spoiler Header +Spoiler content. These lines won't be visible until the user expands the spoiler. +``` +~~~ + +The spoiler will initially display in a collapsed form: + +![](/static/images/help/spoiler-collapsed.png) + +Clicking the arrow will expand the spoiler content: + +![](/static/images/help/spoiler-expanded.png) + ## Emoji and emoticons To translate emoticons into emoji, you'll need to diff --git a/tools/setup/lang.json b/tools/setup/lang.json index c60d3fadd76a5..705936d25db10 100644 --- a/tools/setup/lang.json +++ b/tools/setup/lang.json @@ -49,6 +49,7 @@ "scala": 21, "scheme": 14, "sql": 32, + "spoiler": 50, "swift": 41, "tex": 40, "text": 1, diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 9eb6a5f5b67b2..9c3d18f2c782f 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -128,6 +128,7 @@ EXEMPT_FILES = { 'static/js/settings_ui.js', 'static/js/settings_users.js', 'static/js/setup.js', + 'static/js/spoilers.js', 'static/js/starred_messages.js', 'static/js/stream_color.js', 'static/js/stream_create.js', diff --git a/zerver/lib/bugdown/fenced_code.py b/zerver/lib/bugdown/fenced_code.py index 4823d728d4a32..3a800826033cf 100644 --- a/zerver/lib/bugdown/fenced_code.py +++ b/zerver/lib/bugdown/fenced_code.py @@ -102,6 +102,13 @@ \\}? ) # language, like ".py" or "{javascript}" [ ]* # spaces + ( + \\{?\\.? + (?P
+ [^~`]* + ) + \\}? + ) # header for features that use fenced block header syntax (like spoilers) $ """, re.VERBOSE) @@ -155,13 +162,16 @@ def done(self) -> None: raise NotImplementedError() def generic_handler(processor: Any, output: MutableSequence[str], - fence: str, lang: str, + fence: str, lang: str, header: str, run_content_validators: bool=False, default_language: Optional[str]=None) -> BaseHandler: + lang = lang.lower() if lang in ('quote', 'quoted'): return QuoteHandler(processor, output, fence, default_language) elif lang == 'math': return TexHandler(processor, output, fence) + elif lang == 'spoiler': + return SpoilerHandler(processor, output, fence, header) else: return CodeHandler(processor, output, fence, lang, run_content_validators) @@ -172,9 +182,11 @@ def check_for_new_fence(processor: Any, output: MutableSequence[str], line: str, if m: fence = m.group('fence') lang = m.group('lang') + header = m.group('header') if not lang and default_language: lang = default_language - handler = generic_handler(processor, output, fence, lang, run_content_validators, default_language) + handler = generic_handler(processor, output, fence, lang, header, + run_content_validators, default_language) processor.push(handler) else: output.append(line) @@ -251,6 +263,37 @@ def done(self) -> None: self.output.append('') self.processor.pop() + +class SpoilerHandler(BaseHandler): + def __init__(self, processor: Any, output: MutableSequence[str], + fence: str, spoiler_header: str) -> None: + self.processor = processor + self.output = output + self.fence = fence + self.spoiler_header = spoiler_header + self.lines: List[str] = [] + + def handle_line(self, line: str) -> None: + if line.rstrip() == self.fence: + self.done() + else: + check_for_new_fence(self.processor, self.lines, line) + + def done(self) -> None: + if len(self.lines) == 0: + # No content, do nothing + return + else: + header = self.spoiler_header + text = '\n'.join(self.lines) + + text = self.processor.format_spoiler(header, text) + processed_lines = text.split('\n') + self.output.append('') + self.output.extend(processed_lines) + self.output.append('') + self.processor.pop() + class TexHandler(BaseHandler): def __init__(self, processor: Any, output: MutableSequence[str], fence: str) -> None: self.processor = processor @@ -359,6 +402,20 @@ def format_quote(self, text: str) -> str: quoted_paragraphs.append("\n".join("> " + line for line in lines if line != '')) return "\n\n".join(quoted_paragraphs) + def format_spoiler(self, header: str, text: str) -> str: + output = [] + header_div_open_html = '
' + end_header_start_content_html = '
' + + output.append(self.placeholder(header_div_open_html)) + output.append(header) + output.append(self.placeholder(end_header_start_content_html)) + output.append(text) + output.append(self.placeholder(footer_html)) + return "\n\n".join(output) + def format_tex(self, text: str) -> str: paragraphs = text.split("\n\n") tex_paragraphs = [] diff --git a/zerver/tests/fixtures/markdown_test_cases.json b/zerver/tests/fixtures/markdown_test_cases.json index ed4ad79f3fb64..9d726c7f8bb8c 100644 --- a/zerver/tests/fixtures/markdown_test_cases.json +++ b/zerver/tests/fixtures/markdown_test_cases.json @@ -853,6 +853,38 @@ "expected_output": "
    \n
  1. \n

    A

    \n
  2. \n
  3. \n

    B

    \n
  4. \n
  5. \n

    C

    \n
  6. \n
  7. \n

    D
    \nordinary paragraph

    \n
  8. \n
  9. \n

    AA

    \n
  10. \n
  11. \n

    BB

    \n
  12. \n
", "marked_expected_output": "
    \n
  1. A

    \n
  2. \n
  3. B

    \n
  4. \n
  5. C

    \n
  6. \n
  7. D
    \nordinary paragraph

    \n
  8. \n
  9. AA

    \n
  10. \n
  11. BB

    \n
  12. \n
", "text_content": "1. A\n2. B\n3. C\n4. D\nordinary paragraph\n5. AA\n6. BB" + }, + { + "name": "spoilers_fenced_spoiler", + "input": "```spoiler header\ncontent\n```\noutside spoiler\n", + "expected_output": "
\n\n

header

\n
\n\n

content

\n
\n\n

outside spoiler

" + }, + { + "name": "spoilers_empty_header", + "input": "```spoiler\ncontent\n```\noutside spoiler\n", + "expected_output": "
\n\n
\n\n

content

\n
\n\n

outside spoiler

" + }, + { + "name": "spoilers_script_tags", + "input": "```spoiler \n\n```", + "expected_output": "
\n\n

<script>alert(1)</script>

\n
\n\n

<script>alert(1)</script>

\n
", + "marked_expected_output": "
\n\n

<script>alert(1)</script>\n\n

\n
\n\n

<script>alert(1)</script>\n\n

\n
" + }, + { + "name": "spoilers_block_quote", + "input": "~~~quote\n```spoiler header\ncontent\n```\noutside spoiler\n~~~\noutside quote", + "expected_output": "
\n
\n\n

header

\n
\n\n

content

\n
\n\n

outside spoiler

\n
\n

outside quote

" + }, + { + "name": "spoilers_with_header_markdown", + "input": "```spoiler [Header](https://example.com) :smile:\ncontent\n```", + "expected_output": "
\n\n

Header :smile:

\n
\n\n

content

\n
" + }, + { + "name": "spoiler_with_inline_image", + "input": "```spoiler header\nContent http://example.com/image.png\n```", + "expected_output": "
\n\n

header

\n
", + "marked_expected_output": "
\n\n

header

\n
" } ], "linkify_tests": [