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 <dylnuge@gmail.com>
This commit is contained in:
Sara Gulotta 2020-04-04 16:14:34 -04:00 committed by Tim Abbott
parent 54604257e0
commit 1cb040647b
19 changed files with 383 additions and 7 deletions

View File

@ -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,

View File

@ -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 = '<a class="spoiler-button" aria-expanded="false"><span class="spoiler-arrow"></span></a>';
$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 = '<a class="spoiler-button" aria-expanded="false"><span class="spoiler-arrow"></span></a>';
$header.html('');
rm.update_elements($content);
assert.equal(toggle_button_html + '<p>translated: Spoiler</p>', $header.html());
});

View File

@ -84,6 +84,7 @@ zrequire('color_data');
zrequire('stream_data');
zrequire('muting');
zrequire('condense');
zrequire('spoilers');
zrequire('lightbox');
zrequire('overlays');
zrequire('invite');

View File

@ -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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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

View File

@ -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");
}

View File

@ -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 = '<div class="spoiler-block"><div class="spoiler-header">';
const end_header_start_content_html = '</div><div class="spoiler-content" aria-hidden="true">';
const footer_html = '</div></div>';
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);

View File

@ -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(`<p>${i18n.t('Spoiler')}</p>`);
}
// Add the expand/collapse button to spoiler blocks
const toggle_button_html = '<a class="spoiler-button" aria-expanded="false"><span class="spoiler-arrow"></span></a>';
$(this).prepend(toggle_button_html);
});
// Display emoji (including realm emoji) as text if
// page_params.emojiset is 'text'.
if (page_params.emojiset === 'text') {

66
static/js/spoilers.js Normal file
View File

@ -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;

View File

@ -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();

View File

@ -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;

View File

@ -128,6 +128,22 @@ Quoted block
```</td>
<td class="rendered_markdown"><blockquote><p>Quoted block</p></blockquote></td>
</tr>
<tr>
<td class="preserve_spaces">```spoiler Always visible heading
This text won't be visible until the user clicks.
```</td>
<td class="rendered_markdown">
<div class="spoiler-block">
<div class="spoiler-header"><a class="spoiler-button"><span class="spoiler-arrow"></span></a>
<p>Always visible heading</p>
</div>
<div class="spoiler-content">
<p>This text won't be visible until the user clicks.</p>
</div>
</div>
</td>
</tr>
<tr>
<td>Some inline math $$ e^{i \pi } + 1 = 0 $$</td>
<td class="rendered_markdown">

View File

@ -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

View File

@ -49,6 +49,7 @@
"scala": 21,
"scheme": 14,
"sql": 32,
"spoiler": 50,
"swift": 41,
"tex": 40,
"text": 1,

View File

@ -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',

View File

@ -102,6 +102,13 @@ FENCE_RE = re.compile("""
\\}?
) # language, like ".py" or "{javascript}"
[ ]* # spaces
(
\\{?\\.?
(?P<header>
[^~`]*
)
\\}?
) # header for features that use fenced block header syntax (like spoilers)
$
""", re.VERBOSE)
@ -155,13 +162,16 @@ class BaseHandler:
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 @@ class QuoteHandler(BaseHandler):
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 @@ class FencedBlockPreprocessor(markdown.preprocessors.Preprocessor):
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 = '<div class="spoiler-block"><div class="spoiler-header">'
end_header_start_content_html = '</div><div class="spoiler-content"' \
' aria-hidden="true">'
footer_html = '</div></div>'
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 = []

View File

@ -853,6 +853,38 @@
"expected_output": "<ol>\n<li>\n<p>A</p>\n</li>\n<li>\n<p>B</p>\n</li>\n<li>\n<p>C</p>\n</li>\n<li>\n<p>D<br>\nordinary paragraph</p>\n</li>\n<li>\n<p>AA</p>\n</li>\n<li>\n<p>BB</p>\n</li>\n</ol>",
"marked_expected_output": "<ol>\n<li><p>A</p>\n</li>\n<li><p>B</p>\n</li>\n<li><p>C</p>\n</li>\n<li><p>D<br>\nordinary paragraph</p>\n</li>\n<li><p>AA</p>\n</li>\n<li><p>BB</p>\n</li>\n</ol>",
"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": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>content</p>\n</div></div>\n\n<p>outside spoiler</p>"
},
{
"name": "spoilers_empty_header",
"input": "```spoiler\ncontent\n```\noutside spoiler\n",
"expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>content</p>\n</div></div>\n\n<p>outside spoiler</p>"
},
{
"name": "spoilers_script_tags",
"input": "```spoiler <script>alert(1)</script>\n<script>alert(1)</script>\n```",
"expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>\n</div></div>",
"marked_expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p>&lt;script&gt;alert(1)&lt;/script&gt;\n\n</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>&lt;script&gt;alert(1)&lt;/script&gt;\n\n</p>\n</div></div>"
},
{
"name": "spoilers_block_quote",
"input": "~~~quote\n```spoiler header\ncontent\n```\noutside spoiler\n~~~\noutside quote",
"expected_output": "<blockquote>\n<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>content</p>\n</div></div>\n\n<p>outside spoiler</p>\n</blockquote>\n<p>outside quote</p>"
},
{
"name": "spoilers_with_header_markdown",
"input": "```spoiler [Header](https://example.com) :smile:\ncontent\n```",
"expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p><a href=\"https://example.com\">Header</a> <span aria-label=\"smile\" class=\"emoji emoji-263a\" role=\"img\" title=\"smile\">:smile:</span></p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>content</p>\n</div></div>"
},
{
"name": "spoiler_with_inline_image",
"input": "```spoiler header\nContent http://example.com/image.png\n```",
"expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>Content <a href=\"http://example.com/image.png\">http://example.com/image.png</a></p>\n<div class=\"message_inline_image\"><a href=\"http://example.com/image.png\"><img data-src-fullsize=\"/thumbnail?url=http%3A%2F%2Fexample.com%2Fimage.png&amp;size=full\" src=\"/thumbnail?url=http%3A%2F%2Fexample.com%2Fimage.png&amp;size=thumbnail\"></a></div></div></div>",
"marked_expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>Content <a href=\"http://example.com/image.png\">http://example.com/image.png</a></p>\n</div></div>"
}
],
"linkify_tests": [