From 58172fe21a22323a004d9b3ae1313d0f430bceb2 Mon Sep 17 00:00:00 2001 From: Aman Agrawal Date: Sat, 4 Sep 2021 21:17:27 +0530 Subject: [PATCH] compose: Allow user to undo formatting. For text that already has the formatting that the user is trying to apply, we undo the formatting. This gives a nice experience of applying and removing the formatting from text on the same button press. --- frontend_tests/node_tests/compose_ui.js | 157 ++++++++++++++++++++++++ static/js/compose_ui.js | 142 ++++++++++++++++++++- 2 files changed, 294 insertions(+), 5 deletions(-) diff --git a/frontend_tests/node_tests/compose_ui.js b/frontend_tests/node_tests/compose_ui.js index 33101d089f..f5e1e560bc 100644 --- a/frontend_tests/node_tests/compose_ui.js +++ b/frontend_tests/node_tests/compose_ui.js @@ -460,6 +460,163 @@ run_test("test_compose_height_changes", ({override}) => { assert.ok(!compose_box_top_set); }); +run_test("format_text", () => { + let set_text = ""; + let wrap_selection_called = false; + let wrap_syntax = ""; + + mock_esm("text-field-edit", { + set: (field, text) => { + set_text = text; + }, + wrapSelection: (field, syntax) => { + wrap_selection_called = true; + wrap_syntax = syntax; + }, + }); + + function reset_state() { + set_text = ""; + wrap_selection_called = false; + wrap_syntax = ""; + } + + const textarea = $("#compose-textarea"); + textarea.get = () => ({ + setSelectionRange: () => {}, + }); + + function init_textarea(val, range) { + textarea.val = () => val; + textarea.range = () => range; + } + + const italic_syntax = "*"; + const bold_syntax = "**"; + + // Bold selected text + reset_state(); + init_textarea("abc", { + start: 0, + end: 3, + text: "abc", + length: 3, + }); + compose_ui.format_text(textarea, "bold"); + assert.equal(set_text, ""); + assert.equal(wrap_selection_called, true); + assert.equal(wrap_syntax, bold_syntax); + + // Undo bold selected text, syntax not selected + reset_state(); + init_textarea("**abc**", { + start: 2, + end: 5, + text: "abc", + length: 7, + }); + compose_ui.format_text(textarea, "bold"); + assert.equal(set_text, "abc"); + assert.equal(wrap_selection_called, false); + + // Undo bold selected text, syntax selected + reset_state(); + init_textarea("**abc**", { + start: 0, + end: 7, + text: "**abc**", + length: 7, + }); + compose_ui.format_text(textarea, "bold"); + assert.equal(set_text, "abc"); + assert.equal(wrap_selection_called, false); + + // Italic selected text + reset_state(); + init_textarea("abc", { + start: 0, + end: 3, + text: "abc", + length: 3, + }); + compose_ui.format_text(textarea, "italic"); + assert.equal(set_text, ""); + assert.equal(wrap_selection_called, true); + assert.equal(wrap_syntax, italic_syntax); + + // Undo italic selected text, syntax not selected + reset_state(); + init_textarea("*abc*", { + start: 1, + end: 4, + text: "abc", + length: 3, + }); + compose_ui.format_text(textarea, "italic"); + assert.equal(set_text, "abc"); + assert.equal(wrap_selection_called, false); + + // Undo italic selected text, syntax selected + reset_state(); + init_textarea("*abc*", { + start: 0, + end: 5, + text: "*abc*", + length: 5, + }); + compose_ui.format_text(textarea, "italic"); + assert.equal(set_text, "abc"); + assert.equal(wrap_selection_called, false); + + // Undo bold selected text, text is both italic and bold, syntax not selected. + reset_state(); + init_textarea("***abc***", { + start: 3, + end: 6, + text: "abc", + length: 3, + }); + compose_ui.format_text(textarea, "bold"); + assert.equal(set_text, "*abc*"); + assert.equal(wrap_selection_called, false); + + // Undo bold selected text, text is both italic and bold, syntax selected. + reset_state(); + init_textarea("***abc***", { + start: 0, + end: 9, + text: "***abc***", + length: 9, + }); + compose_ui.format_text(textarea, "bold"); + assert.equal(set_text, "*abc*"); + assert.equal(wrap_selection_called, false); + + // Undo italic selected text, text is both italic and bold, syntax not selected. + reset_state(); + init_textarea("***abc***", { + start: 3, + end: 6, + text: "abc", + length: 3, + }); + compose_ui.format_text(textarea, "italic"); + assert.equal(set_text, "**abc**"); + assert.equal(wrap_selection_called, false); + + // Undo italic selected text, text is both italic and bold, syntax selected. + reset_state(); + init_textarea("***abc***", { + start: 0, + end: 9, + text: "***abc***", + length: 9, + }); + compose_ui.format_text(textarea, "italic"); + assert.equal(set_text, "**abc**"); + assert.equal(wrap_selection_called, false); +}); + run_test("markdown_shortcuts", ({override}) => { let format_text_type; override(compose_ui, "format_text", (textarea, type) => { diff --git a/static/js/compose_ui.js b/static/js/compose_ui.js index 87a94b5938..e5d8b7a4f5 100644 --- a/static/js/compose_ui.js +++ b/static/js/compose_ui.js @@ -1,6 +1,6 @@ import autosize from "autosize"; import $ from "jquery"; -import {wrapSelection} from "text-field-edit"; +import {set, wrapSelection} from "text-field-edit"; import * as common from "./common"; import {$t} from "./i18n"; @@ -221,17 +221,149 @@ export function handle_keyup(event, textarea) { } export function format_text(textarea, type) { + const italic_syntax = "*"; + const bold_syntax = "**"; + const bold_and_italic_syntax = "***"; + let is_selected_text_italic = false; + let is_inner_text_italic = false; const field = textarea.get(0); const range = textarea.range(); + let text = textarea.val(); + const selected_text = range.text; + + const is_selection_bold = () => + // First check if there are enough characters before/after selection. + range.start >= bold_syntax.length && + text.length - range.end >= bold_syntax.length && + // And then if the characters have bold_syntax around them. + text.slice(range.start - bold_syntax.length, range.start) === bold_syntax && + text.slice(range.end, range.end + bold_syntax.length) === bold_syntax; + + const is_inner_text_bold = () => + // Check if selected text itself has bold_syntax inside it. + range.length > 4 && + selected_text.slice(0, bold_syntax.length) === bold_syntax && + selected_text.slice(-bold_syntax.length) === bold_syntax; switch (type) { case "bold": - // Ctrl + B: Convert selected text to bold text - wrapSelection(field, "**"); + // Ctrl + B: Toggle bold syntax on selection. + + // If the selection is already surrounded by bold syntax, + // remove it rather than adding another copy. + if (is_selection_bold()) { + // Remove the bold_syntax from text. + text = + text.slice(0, range.start - bold_syntax.length) + + text.slice(range.start, range.end) + + text.slice(range.end + bold_syntax.length); + set(field, text); + field.setSelectionRange( + range.start - bold_syntax.length, + range.end - bold_syntax.length, + ); + break; + } else if (is_inner_text_bold()) { + // Remove bold syntax inside the selection, if present. + text = + text.slice(0, range.start) + + text.slice(range.start + bold_syntax.length, range.end - bold_syntax.length) + + text.slice(range.end); + set(field, text); + field.setSelectionRange(range.start, range.end - bold_syntax.length * 2); + break; + } + + // Otherwise, we don't have bold syntax, so we add it. + wrapSelection(field, bold_syntax); break; case "italic": - // Ctrl + I: Convert selected text to italic text - wrapSelection(field, "*"); + // Ctrl + I: Toggle italic syntax on selection. This is + // much more complex than toggling bold syntax, because of + // the following subtle detail: If our selection is + // **foo**, toggling italics should add italics, since in + // fact it's just bold syntax, even though with *foo* and + // ***foo*** should remove italics. + + // If the text is already italic, we remove the italic_syntax from text. + if (range.start >= 1 && text.length - range.end >= italic_syntax.length) { + // If text has italic_syntax around it. + const has_italic_syntax = + text.slice(range.start - italic_syntax.length, range.start) === italic_syntax && + text.slice(range.end, range.end + italic_syntax.length) === italic_syntax; + + if (is_selection_bold()) { + // If text has bold_syntax around it. + if ( + range.start >= 3 && + text.length - range.end >= bold_and_italic_syntax.length + ) { + // If text is both bold and italic. + const has_bold_and_italic_syntax = + text.slice(range.start - bold_and_italic_syntax.length, range.start) === + bold_and_italic_syntax && + text.slice(range.end, range.end + bold_and_italic_syntax.length) === + bold_and_italic_syntax; + if (has_bold_and_italic_syntax) { + is_selected_text_italic = true; + } + } + } else if (has_italic_syntax) { + // If text is only italic. + is_selected_text_italic = true; + } + } + + if (is_selected_text_italic) { + // If text has italic syntax around it, we remove the italic syntax. + text = + text.slice(0, range.start - italic_syntax.length) + + text.slice(range.start, range.end) + + text.slice(range.end + italic_syntax.length); + set(field, text); + field.setSelectionRange( + range.start - italic_syntax.length, + range.end - italic_syntax.length, + ); + break; + } else if ( + selected_text.length > italic_syntax.length * 2 && + // If the selected text contains italic syntax + selected_text.slice(0, italic_syntax.length) === italic_syntax && + selected_text.slice(-italic_syntax.length) === italic_syntax + ) { + if (is_inner_text_bold()) { + if ( + selected_text.length > bold_and_italic_syntax.length * 2 && + selected_text.slice(0, bold_and_italic_syntax.length) === + bold_and_italic_syntax && + selected_text.slice(-bold_and_italic_syntax.length) === + bold_and_italic_syntax + ) { + // If selected text is both bold and italic. + is_inner_text_italic = true; + } + } else { + // If selected text is only italic. + is_inner_text_italic = true; + } + } + + if (is_inner_text_italic) { + // We remove the italic_syntax from within the selected text. + text = + text.slice(0, range.start) + + text.slice( + range.start + italic_syntax.length, + range.end - italic_syntax.length, + ) + + text.slice(range.end); + set(field, text); + field.setSelectionRange(range.start, range.end - italic_syntax.length * 2); + break; + } + + wrapSelection(field, italic_syntax); break; case "link": { // Ctrl + L: Insert a link to selected text