compose: Add button to format numbered lists.

A formatting button below the compose box can format the selected text
by numbering or unnumbering the selected (partially or completely)
lines. The behaviour is inspired by GitHub's bulleted list formatting.

Fixes part of: #20305.
This commit is contained in:
N-Shar-ma 2022-07-12 14:25:06 +05:30 committed by Tim Abbott
parent 3c0a6d8ee3
commit 836363b0f5
3 changed files with 124 additions and 58 deletions

View File

@ -358,7 +358,7 @@ export function format_text($textarea, type, inserted_content) {
// where we want to especially preserve any selected new line character
// before the selected text, as it is conventionally depicted with a highlight
// at the end of the previous line, which we would like to format.
const TRIM_ONLY_END_TYPES = ["bulleted"];
const TRIM_ONLY_END_TYPES = ["bulleted", "numbered"];
let start_trim_length;
if (TRIM_ONLY_END_TYPES.includes(type)) {
@ -431,6 +431,74 @@ export function format_text($textarea, type, inserted_content) {
};
};
const format_list = (type) => {
let is_marked;
let mark;
let strip_marking;
if (type === "bulleted") {
is_marked = bulleted_numbered_list_util.is_bulleted;
mark = (line) => "- " + line;
strip_marking = bulleted_numbered_list_util.strip_bullet;
} else {
is_marked = bulleted_numbered_list_util.is_numbered;
mark = (line, i) => i + 1 + ". " + line;
strip_marking = bulleted_numbered_list_util.strip_numbering;
}
// We toggle complete lines even when they are partially selected (and just selecting the
// newline character after a line counts as partial selection too).
const sections = section_off_selected_lines();
let {before_lines, selected_lines, after_lines} = sections;
const {separating_new_line_before, separating_new_line_after} = sections;
// If there is even a single unmarked line selected, we mark all.
const should_mark = selected_lines.split("\n").some((line) => !is_marked(line));
if (should_mark) {
selected_lines = selected_lines
.split("\n")
.map((line, i) => mark(line, i))
.join("\n");
// We always ensure a blank line before and after the list, as we want
// a clean separation between the list and the rest of the text, especially
// when the markdown is rendered.
// Add blank line between text before and list if not already present.
if (before_lines.length && before_lines.at(-1) !== "\n") {
before_lines += "\n";
}
// Add blank line between list and rest of text if not already present.
if (after_lines.length && after_lines.at(0) !== "\n") {
after_lines = "\n" + after_lines;
}
} else {
// Unmark all marked lines by removing the marking syntax characters.
selected_lines = selected_lines
.split("\n")
.map((line) => strip_marking(line))
.join("\n");
}
// Restore the separating newlines that were removed by section_off_selected_lines.
if (separating_new_line_before) {
before_lines += "\n";
}
if (separating_new_line_after) {
after_lines = "\n" + after_lines;
}
text = before_lines + selected_lines + after_lines;
set(field, text);
// If no text was selected, that is, marking was added to the line with the
// cursor, nothing will be selected and the cursor will remain as it was.
if (selected_text === "") {
field.setSelectionRange(
before_lines.length + selected_lines.length,
before_lines.length + selected_lines.length,
);
} else {
field.setSelectionRange(
before_lines.length,
before_lines.length + selected_lines.length,
);
}
};
switch (type) {
case "bold":
// Ctrl + B: Toggle bold syntax on selection.
@ -551,64 +619,10 @@ export function format_text($textarea, type, inserted_content) {
wrapSelection(field, italic_syntax);
break;
case "bulleted": {
// We toggle complete lines even when they are partially selected (and just selecting the
// newline character after a line counts as partial selection too).
const sections = section_off_selected_lines();
let {before_lines, selected_lines, after_lines} = sections;
const {separating_new_line_before, separating_new_line_after} = sections;
// If there is even a single unbulleted line selected, we bullet all.
const should_bullet = selected_lines
.split("\n")
.some((line) => !bulleted_numbered_list_util.is_bulleted(line));
if (should_bullet) {
selected_lines = selected_lines
.split("\n")
.map((line) => "- " + line)
.join("\n");
// We always ensure a blank line before and after the list, as we want
// a clean separation between the list and the rest of the text, especially
// when the markdown is rendered.
// Add blank line between text before and list if not already present.
if (before_lines.length && before_lines.at(-1) !== "\n") {
before_lines += "\n";
}
// Add blank line between list and rest of text if not already present.
if (after_lines.length && after_lines.at(0) !== "\n") {
after_lines = "\n" + after_lines;
}
} else {
// Unbullet all bulleted lines by removing the 2 bullet syntax characters.
selected_lines = selected_lines
.split("\n")
.map((line) => bulleted_numbered_list_util.strip_bullet(line))
.join("\n");
}
// Restore the separating newlines that were removed by section_off_selected_lines.
if (separating_new_line_before) {
before_lines += "\n";
}
if (separating_new_line_after) {
after_lines = "\n" + after_lines;
}
text = before_lines + selected_lines + after_lines;
set(field, text);
// If no text was selected, that is, bullet was added to the line with the
// cursor, nothing will be selected and the cursor will remain as it was.
if (selected_text === "") {
field.setSelectionRange(
before_lines.length + selected_lines.length,
before_lines.length + selected_lines.length,
);
} else {
field.setSelectionRange(
before_lines.length,
before_lines.length + selected_lines.length,
);
}
case "bulleted":
case "numbered":
format_list(type);
break;
}
case "link": {
// Ctrl + L: Insert a link to selected text
wrapSelection(field, "[", "](url)");

View File

@ -2,6 +2,7 @@
<a role="button" data-format-type="bold" class="compose_control_button fa fa-bold formatting_button" aria-label="{{t 'Bold' }}" {{#unless preview_mode_on}} tabindex=0 {{/unless}} data-tippy-content="{{t 'Bold' }}"></a>
<a role="button" data-format-type="italic" class="compose_control_button fa fa-italic formatting_button" aria-label="{{t 'Italic' }}" {{#unless preview_mode_on}} tabindex=0 {{/unless}} data-tippy-content="{{t 'Italic' }}"></a>
<a role="button" data-format-type="bulleted" class="compose_control_button fa fa-list-ul formatting_button" aria-label="{{t 'Bulleted list' }}" {{#unless preview_mode_on}} tabindex=0 {{/unless}} data-tippy-content="{{t 'Bulleted list' }}"></a>
<a role="button" data-format-type="numbered" class="compose_control_button fa fa-list-ol formatting_button" aria-label="{{t 'Numbered list' }}" {{#unless preview_mode_on}} tabindex=0 {{/unless}} data-tippy-content="{{t 'Numbered list' }}"></a>
<a role="button" data-format-type="link" class="compose_control_button fa fa-link formatting_button" aria-label="{{t 'Link' }}" {{#unless preview_mode_on}} tabindex=0 {{/unless}} data-tippy-content="{{t 'Link' }}"></a>
<div class="divider hide-sm show-md hide-mc">|</div>
</div>

View File

@ -691,6 +691,57 @@ run_test("format_text", ({override}) => {
compose_ui.format_text($textarea, "bulleted");
assert.equal(set_text, "first_item\nsecond_item");
assert.equal(wrap_selection_called, false);
// Test numbered list toggling on
reset_state();
init_textarea("first_item\nsecond_item", {
start: 0,
end: 22,
text: "first_item\nsecond_item",
length: 22,
});
compose_ui.format_text($textarea, "numbered");
assert.equal(set_text, "1. first_item\n2. second_item");
assert.equal(wrap_selection_called, false);
// Test numbered list toggling off
reset_state();
init_textarea("1. first_item\n2. second_item", {
start: 0,
end: 28,
text: "1. first_item\n2. second_item",
length: 28,
});
compose_ui.format_text($textarea, "numbered");
assert.equal(set_text, "first_item\nsecond_item");
assert.equal(wrap_selection_called, false);
// Test numbered list toggling with newline at end
reset_state();
init_textarea("first_item\nsecond_item\n", {
start: 0,
end: 23,
text: "first_item\nsecond_item\n",
length: 23,
});
compose_ui.format_text($textarea, "numbered");
assert.equal(set_text, "1. first_item\n2. second_item\n");
assert.equal(wrap_selection_called, false);
// Test numbered list toggling on with partially selected lines
reset_state();
init_textarea("before_first\nfirst_item\nsecond_item\nafter_last", {
start: 15,
end: 33,
text: "rst_item\nsecond_it",
length: 18,
});
compose_ui.format_text($textarea, "numbered");
// Notice the blank lines inserted right before and after the list to visually demarcate it.
// Had the blank line after `second_item` not been inserted, `after_last` would have been
// (wrongly) indented as part of the list's last item too.
assert.equal(set_text, "before_first\n\n1. first_item\n2. second_item\n\nafter_last");
assert.equal(wrap_selection_called, false);
});
run_test("markdown_shortcuts", ({override_rewire}) => {