mirror of https://github.com/zulip/zulip.git
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:
parent
54604257e0
commit
1cb040647b
|
@ -303,6 +303,7 @@
|
||||||
"settings_ui": false,
|
"settings_ui": false,
|
||||||
"settings_user_groups": false,
|
"settings_user_groups": false,
|
||||||
"settings_users": false,
|
"settings_users": false,
|
||||||
|
"spoilers": false,
|
||||||
"starred_messages": false,
|
"starred_messages": false,
|
||||||
"stream_color": false,
|
"stream_color": false,
|
||||||
"stream_create": false,
|
"stream_create": false,
|
||||||
|
|
|
@ -70,6 +70,7 @@ const get_content_element = () => {
|
||||||
$content.set_find_results('a.stream-topic', $array([]));
|
$content.set_find_results('a.stream-topic', $array([]));
|
||||||
$content.set_find_results('span.timestamp', $array([]));
|
$content.set_find_results('span.timestamp', $array([]));
|
||||||
$content.set_find_results('.emoji', $array([]));
|
$content.set_find_results('.emoji', $array([]));
|
||||||
|
$content.set_find_results('div.spoiler-header', $array([]));
|
||||||
return $content;
|
return $content;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -197,4 +198,34 @@ run_test('emoji', () => {
|
||||||
rm.update_elements($content);
|
rm.update_elements($content);
|
||||||
|
|
||||||
assert(called);
|
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());
|
||||||
});
|
});
|
||||||
|
|
|
@ -84,6 +84,7 @@ zrequire('color_data');
|
||||||
zrequire('stream_data');
|
zrequire('stream_data');
|
||||||
zrequire('muting');
|
zrequire('muting');
|
||||||
zrequire('condense');
|
zrequire('condense');
|
||||||
|
zrequire('spoilers');
|
||||||
zrequire('lightbox');
|
zrequire('lightbox');
|
||||||
zrequire('overlays');
|
zrequire('overlays');
|
||||||
zrequire('invite');
|
zrequire('invite');
|
||||||
|
|
|
@ -144,6 +144,10 @@ exports.make_new_elem = function (selector, opts) {
|
||||||
classes.set(class_name, true);
|
classes.set(class_name, true);
|
||||||
return self;
|
return self;
|
||||||
},
|
},
|
||||||
|
append: function (arg) {
|
||||||
|
html = html + arg;
|
||||||
|
return self;
|
||||||
|
},
|
||||||
attr: function (name, val) {
|
attr: function (name, val) {
|
||||||
if (val === undefined) {
|
if (val === undefined) {
|
||||||
return attrs.get(name);
|
return attrs.get(name);
|
||||||
|
@ -284,6 +288,10 @@ exports.make_new_elem = function (selector, opts) {
|
||||||
parents_selector + ' in ' + selector);
|
parents_selector + ' in ' + selector);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
prepend: function (arg) {
|
||||||
|
html = arg + html;
|
||||||
|
return self;
|
||||||
|
},
|
||||||
prop: function (name, val) {
|
prop: function (name, val) {
|
||||||
if (val === undefined) {
|
if (val === undefined) {
|
||||||
return properties.get(name);
|
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 |
|
@ -199,6 +199,7 @@ import "../settings_ui.js";
|
||||||
import "../search_pill.js";
|
import "../search_pill.js";
|
||||||
import "../search_pill_widget.js";
|
import "../search_pill_widget.js";
|
||||||
import "../stream_ui_updates.js";
|
import "../stream_ui_updates.js";
|
||||||
|
import "../spoilers.js";
|
||||||
|
|
||||||
// Import Styles
|
// Import Styles
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ exports.initialize = function () {
|
||||||
function is_clickable_message_element(target) {
|
function is_clickable_message_element(target) {
|
||||||
return target.is("a") || target.is("img.message_inline_image") || target.is("img.twitter-avatar") ||
|
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("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");
|
target.is(".highlight") && target.parent().is("a");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,14 +5,20 @@
|
||||||
// auto-completing code blocks missing a trailing close.
|
// auto-completing code blocks missing a trailing close.
|
||||||
|
|
||||||
// See backend fenced_code.py:71 for associated regexp
|
// See backend fenced_code.py:71 for associated regexp
|
||||||
const fencestr = "^(~{3,}|`{3,})" + // Opening Fence
|
const fencestr = "^(~{3,}|`{3,})" + // Opening Fence
|
||||||
"[ ]*" + // Spaces
|
"[ ]*" + // Spaces
|
||||||
"(" +
|
"(" +
|
||||||
"\\{?\\.?" +
|
"\\{?\\.?" +
|
||||||
"([a-zA-Z0-9_+-./#]*)" + // Language
|
"([a-zA-Z0-9_+-./#]*)" + // Language
|
||||||
"\\}?" +
|
"\\}?" +
|
||||||
|
")" +
|
||||||
"[ ]*" + // Spaces
|
"[ ]*" + // Spaces
|
||||||
")$";
|
"(" +
|
||||||
|
"\\{?\\.?" +
|
||||||
|
"([^~`]*)" + // Header (see fenced_code.py)
|
||||||
|
"\\}?" +
|
||||||
|
")" +
|
||||||
|
"$";
|
||||||
const fence_re = new RegExp(fencestr);
|
const fence_re = new RegExp(fencestr);
|
||||||
|
|
||||||
// Default stashing function does nothing
|
// 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) {
|
exports.set_stash_func = function (stash_handler) {
|
||||||
stash_func = stash_handler;
|
stash_func = stash_handler;
|
||||||
};
|
};
|
||||||
|
@ -62,7 +82,7 @@ exports.process_fenced_code = function (content) {
|
||||||
const handler_stack = [];
|
const handler_stack = [];
|
||||||
let consume_line;
|
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
|
// lang is ignored except for 'quote', as we
|
||||||
// don't do syntax highlighting yet
|
// don't do syntax highlighting yet
|
||||||
return (function () {
|
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 {
|
return {
|
||||||
handle_line: function (line) {
|
handle_line: function (line) {
|
||||||
if (line === fence) {
|
if (line === fence) {
|
||||||
|
@ -146,7 +186,8 @@ exports.process_fenced_code = function (content) {
|
||||||
if (match) {
|
if (match) {
|
||||||
const fence = match[1];
|
const fence = match[1];
|
||||||
const lang = match[3];
|
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);
|
handler_stack.push(handler);
|
||||||
} else {
|
} else {
|
||||||
output_lines.push(line);
|
output_lines.push(line);
|
||||||
|
|
|
@ -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
|
// Display emoji (including realm emoji) as text if
|
||||||
// page_params.emojiset is 'text'.
|
// page_params.emojiset is 'text'.
|
||||||
if (page_params.emojiset === 'text') {
|
if (page_params.emojiset === 'text') {
|
||||||
|
|
|
@ -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;
|
|
@ -438,6 +438,7 @@ exports.initialize_everything = function () {
|
||||||
subs.initialize();
|
subs.initialize();
|
||||||
stream_list.initialize();
|
stream_list.initialize();
|
||||||
condense.initialize();
|
condense.initialize();
|
||||||
|
spoilers.initialize();
|
||||||
lightbox.initialize();
|
lightbox.initialize();
|
||||||
click_handlers.initialize();
|
click_handlers.initialize();
|
||||||
copy_and_paste.initialize();
|
copy_and_paste.initialize();
|
||||||
|
|
|
@ -163,6 +163,89 @@
|
||||||
color: hsl(0, 0%, 50%);
|
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 */
|
/* CSS for message content widgets */
|
||||||
table.tictactoe {
|
table.tictactoe {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
|
|
|
@ -128,6 +128,22 @@ Quoted block
|
||||||
```</td>
|
```</td>
|
||||||
<td class="rendered_markdown"><blockquote><p>Quoted block</p></blockquote></td>
|
<td class="rendered_markdown"><blockquote><p>Quoted block</p></blockquote></td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Some inline math $$ e^{i \pi } + 1 = 0 $$</td>
|
<td>Some inline math $$ e^{i \pi } + 1 = 0 $$</td>
|
||||||
<td class="rendered_markdown">
|
<td class="rendered_markdown">
|
||||||
|
|
|
@ -13,6 +13,7 @@ to allow you to easily format your messages.
|
||||||
* [Code blocks](#code)
|
* [Code blocks](#code)
|
||||||
* [LaTeX](#latex)
|
* [LaTeX](#latex)
|
||||||
* [Quotes](#quotes)
|
* [Quotes](#quotes)
|
||||||
|
* [Spoilers](#spoilers)
|
||||||
* [Emoji and emoticons](#emoji-and-emoticons)
|
* [Emoji and emoticons](#emoji-and-emoticons)
|
||||||
* [Mentions](#mentions)
|
* [Mentions](#mentions)
|
||||||
* [Status messages](#status-messages)
|
* [Status messages](#status-messages)
|
||||||
|
@ -150,6 +151,28 @@ quote in two paragraphs
|
||||||
|
|
||||||
![](/static/images/help/markdown-quotes.png)
|
![](/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
|
## Emoji and emoticons
|
||||||
|
|
||||||
To translate emoticons into emoji, you'll need to
|
To translate emoticons into emoji, you'll need to
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
"scala": 21,
|
"scala": 21,
|
||||||
"scheme": 14,
|
"scheme": 14,
|
||||||
"sql": 32,
|
"sql": 32,
|
||||||
|
"spoiler": 50,
|
||||||
"swift": 41,
|
"swift": 41,
|
||||||
"tex": 40,
|
"tex": 40,
|
||||||
"text": 1,
|
"text": 1,
|
||||||
|
|
|
@ -128,6 +128,7 @@ EXEMPT_FILES = {
|
||||||
'static/js/settings_ui.js',
|
'static/js/settings_ui.js',
|
||||||
'static/js/settings_users.js',
|
'static/js/settings_users.js',
|
||||||
'static/js/setup.js',
|
'static/js/setup.js',
|
||||||
|
'static/js/spoilers.js',
|
||||||
'static/js/starred_messages.js',
|
'static/js/starred_messages.js',
|
||||||
'static/js/stream_color.js',
|
'static/js/stream_color.js',
|
||||||
'static/js/stream_create.js',
|
'static/js/stream_create.js',
|
||||||
|
|
|
@ -102,6 +102,13 @@ FENCE_RE = re.compile("""
|
||||||
\\}?
|
\\}?
|
||||||
) # language, like ".py" or "{javascript}"
|
) # language, like ".py" or "{javascript}"
|
||||||
[ ]* # spaces
|
[ ]* # spaces
|
||||||
|
(
|
||||||
|
\\{?\\.?
|
||||||
|
(?P<header>
|
||||||
|
[^~`]*
|
||||||
|
)
|
||||||
|
\\}?
|
||||||
|
) # header for features that use fenced block header syntax (like spoilers)
|
||||||
$
|
$
|
||||||
""", re.VERBOSE)
|
""", re.VERBOSE)
|
||||||
|
|
||||||
|
@ -155,13 +162,16 @@ class BaseHandler:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def generic_handler(processor: Any, output: MutableSequence[str],
|
def generic_handler(processor: Any, output: MutableSequence[str],
|
||||||
fence: str, lang: str,
|
fence: str, lang: str, header: str,
|
||||||
run_content_validators: bool=False,
|
run_content_validators: bool=False,
|
||||||
default_language: Optional[str]=None) -> BaseHandler:
|
default_language: Optional[str]=None) -> BaseHandler:
|
||||||
|
lang = lang.lower()
|
||||||
if lang in ('quote', 'quoted'):
|
if lang in ('quote', 'quoted'):
|
||||||
return QuoteHandler(processor, output, fence, default_language)
|
return QuoteHandler(processor, output, fence, default_language)
|
||||||
elif lang == 'math':
|
elif lang == 'math':
|
||||||
return TexHandler(processor, output, fence)
|
return TexHandler(processor, output, fence)
|
||||||
|
elif lang == 'spoiler':
|
||||||
|
return SpoilerHandler(processor, output, fence, header)
|
||||||
else:
|
else:
|
||||||
return CodeHandler(processor, output, fence, lang, run_content_validators)
|
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:
|
if m:
|
||||||
fence = m.group('fence')
|
fence = m.group('fence')
|
||||||
lang = m.group('lang')
|
lang = m.group('lang')
|
||||||
|
header = m.group('header')
|
||||||
if not lang and default_language:
|
if not lang and default_language:
|
||||||
lang = 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)
|
processor.push(handler)
|
||||||
else:
|
else:
|
||||||
output.append(line)
|
output.append(line)
|
||||||
|
@ -251,6 +263,37 @@ class QuoteHandler(BaseHandler):
|
||||||
self.output.append('')
|
self.output.append('')
|
||||||
self.processor.pop()
|
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):
|
class TexHandler(BaseHandler):
|
||||||
def __init__(self, processor: Any, output: MutableSequence[str], fence: str) -> None:
|
def __init__(self, processor: Any, output: MutableSequence[str], fence: str) -> None:
|
||||||
self.processor = processor
|
self.processor = processor
|
||||||
|
@ -359,6 +402,20 @@ class FencedBlockPreprocessor(markdown.preprocessors.Preprocessor):
|
||||||
quoted_paragraphs.append("\n".join("> " + line for line in lines if line != ''))
|
quoted_paragraphs.append("\n".join("> " + line for line in lines if line != ''))
|
||||||
return "\n\n".join(quoted_paragraphs)
|
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:
|
def format_tex(self, text: str) -> str:
|
||||||
paragraphs = text.split("\n\n")
|
paragraphs = text.split("\n\n")
|
||||||
tex_paragraphs = []
|
tex_paragraphs = []
|
||||||
|
|
|
@ -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>",
|
"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>",
|
"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"
|
"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><script>alert(1)</script></p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p><script>alert(1)</script></p>\n</div></div>",
|
||||||
|
"marked_expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p><script>alert(1)</script>\n\n</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p><script>alert(1)</script>\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&size=full\" src=\"/thumbnail?url=http%3A%2F%2Fexample.com%2Fimage.png&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": [
|
"linkify_tests": [
|
||||||
|
|
Loading…
Reference in New Issue