Skip to content

Commit

Permalink
markdown: Add support for spoilers.
Browse files Browse the repository at this point in the history
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 zulip#5802.

Co-authored-by: Dylan Nugent <[email protected]>
  • Loading branch information
2 people authored and timabbott committed Jun 16, 2020
1 parent 5460425 commit 1cb0406
Show file tree
Hide file tree
Showing 19 changed files with 383 additions and 7 deletions.
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions frontend_tests/node_tests/rendered_markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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());
});
1 change: 1 addition & 0 deletions frontend_tests/node_tests/ui_init.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ zrequire('color_data');
zrequire('stream_data');
zrequire('muting');
zrequire('condense');
zrequire('spoilers');
zrequire('lightbox');
zrequire('overlays');
zrequire('invite');
Expand Down
8 changes: 8 additions & 0 deletions frontend_tests/zjsunit/zjquery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Binary file added static/images/help/spoiler-collapsed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/images/help/spoiler-expanded.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions static/js/bundles/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion static/js/click_handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
49 changes: 45 additions & 4 deletions static/js/fenced_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
};
Expand All @@ -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 () {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions static/js/rendered_markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
66 changes: 66 additions & 0 deletions static/js/spoilers.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions static/js/ui_init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
83 changes: 83 additions & 0 deletions static/styles/rendered_markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions templates/zerver/app/markdown_help.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@
```</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">
Expand Down
Loading

0 comments on commit 1cb0406

Please sign in to comment.