From 6b13988eaa959568d05e8bc63238b98fa19d1953 Mon Sep 17 00:00:00 2001 From: Yaya Usman <38439166+yaya-usman@users.noreply.github.com> Date: Tue, 19 Apr 2022 12:20:56 +0300 Subject: [PATCH] Fix: "Code formatting button does not escape backticks" (#8181) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/deserialize.ts | 2 +- src/editor/operations.ts | 11 +++-- test/editor/operations-test.ts | 85 ++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index 1fbccf45fff..7d4f94cdc3d 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -32,7 +32,7 @@ function escape(text: string): string { // Finds the length of the longest backtick sequence in the given text, used for // escaping backticks in code blocks -function longestBacktickSequence(text: string): number { +export function longestBacktickSequence(text: string): number { let length = 0; let currentLength = 0; diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 6129681815c..40a438cc562 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -17,6 +17,7 @@ limitations under the License. import Range from "./range"; import { Part, Type } from "./parts"; import { Formatting } from "../components/views/rooms/MessageComposerFormatBar"; +import { longestBacktickSequence } from './deserialize'; /** * Some common queries and transformations on the editor model @@ -181,12 +182,12 @@ export function formatRangeAsCode(range: Range): void { const hasBlockFormatting = (range.length > 0) && range.text.startsWith("```") - && range.text.endsWith("```"); + && range.text.endsWith("```") + && range.text.includes('\n'); const needsBlockFormatting = parts.some(p => p.type === Type.Newline); if (hasBlockFormatting) { - // Remove previously pushed backticks and new lines parts.shift(); parts.pop(); if (parts[0]?.text === "\n" && parts[parts.length - 1]?.text === "\n") { @@ -205,7 +206,10 @@ export function formatRangeAsCode(range: Range): void { parts.push(partCreator.newline()); } } else { - toggleInlineFormat(range, "`"); + const fenceLen = longestBacktickSequence(range.text); + const hasInlineFormatting = range.text.startsWith("`") && range.text.endsWith("`"); + //if it's already formatted untoggle based on fenceLen which returns the max. num of backtick within a text else increase the fence backticks with a factor of 1. + toggleInlineFormat(range, "`".repeat(hasInlineFormatting ? fenceLen : fenceLen + 1)); return; } @@ -240,6 +244,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix // compute paragraph [start, end] indexes const paragraphIndexes = []; let startIndex = 0; + // start at i=2 because we look at i and up to two parts behind to detect paragraph breaks at their end for (let i = 2; i < parts.length; i++) { // paragraph breaks can be denoted in a multitude of ways, diff --git a/test/editor/operations-test.ts b/test/editor/operations-test.ts index b9ab4cc4e85..3e4de224179 100644 --- a/test/editor/operations-test.ts +++ b/test/editor/operations-test.ts @@ -20,8 +20,10 @@ import { toggleInlineFormat, selectRangeOfWordAtCaret, formatRange, + formatRangeAsCode, } from "../../src/editor/operations"; import { Formatting } from "../../src/components/views/rooms/MessageComposerFormatBar"; +import { longestBacktickSequence } from '../../src/editor/deserialize'; const SERIALIZED_NEWLINE = { "text": "\n", "type": "newline" }; @@ -43,6 +45,89 @@ describe('editor/operations: formatting operations', () => { expect(model.serializeParts()).toEqual([{ "text": "hello _world_!", "type": "plain" }]); }); + describe('escape backticks', () => { + it('works for escaping backticks in between texts', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello ` world!"), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(0, false), + model.positionForOffset(13, false)); // hello ` world + + expect(range.parts[0].text.trim().includes("`")).toBeTruthy(); + expect(longestBacktickSequence(range.parts[0].text.trim())).toBe(1); + expect(model.serializeParts()).toEqual([{ "text": "hello ` world!", "type": "plain" }]); + formatRangeAsCode(range); + expect(model.serializeParts()).toEqual([{ "text": "``hello ` world``!", "type": "plain" }]); + }); + + it('escapes longer backticks in between text', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello```world"), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(0, false), + model.getPositionAtEnd()); // hello```world + + expect(range.parts[0].text.includes("`")).toBeTruthy(); + expect(longestBacktickSequence(range.parts[0].text)).toBe(3); + expect(model.serializeParts()).toEqual([{ "text": "hello```world", "type": "plain" }]); + formatRangeAsCode(range); + expect(model.serializeParts()).toEqual([{ "text": "````hello```world````", "type": "plain" }]); + }); + + it('escapes non-consecutive with varying length backticks in between text', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hell```o`w`o``rld"), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(0, false), + model.getPositionAtEnd()); // hell```o`w`o``rld + expect(range.parts[0].text.includes("`")).toBeTruthy(); + expect(longestBacktickSequence(range.parts[0].text)).toBe(3); + expect(model.serializeParts()).toEqual([{ "text": "hell```o`w`o``rld", "type": "plain" }]); + formatRangeAsCode(range); + expect(model.serializeParts()).toEqual([{ "text": "````hell```o`w`o``rld````", "type": "plain" }]); + }); + + it('untoggles correctly if its already formatted', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("```hello``world```"), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(0, false), + model.getPositionAtEnd()); // hello``world + expect(range.parts[0].text.includes("`")).toBeTruthy(); + expect(longestBacktickSequence(range.parts[0].text)).toBe(3); + expect(model.serializeParts()).toEqual([{ "text": "```hello``world```", "type": "plain" }]); + formatRangeAsCode(range); + expect(model.serializeParts()).toEqual([{ "text": "hello``world", "type": "plain" }]); + }); + it('untoggles correctly it contains varying length of backticks between text', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("````hell```o`w`o``rld````"), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(0, false), + model.getPositionAtEnd()); // hell```o`w`o``rld + expect(range.parts[0].text.includes("`")).toBeTruthy(); + expect(longestBacktickSequence(range.parts[0].text)).toBe(4); + expect(model.serializeParts()).toEqual([{ "text": "````hell```o`w`o``rld````", "type": "plain" }]); + formatRangeAsCode(range); + expect(model.serializeParts()).toEqual([{ "text": "hell```o`w`o``rld", "type": "plain" }]); + }); + }); + it('works for parts of words', () => { const renderer = createRenderer(); const pc = createPartCreator();