diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 85a9afbc461..305de446824 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -39,6 +39,10 @@ export enum KeyBindingAction { FormatBold = 'KeyBinding.toggleBoldInComposer', /** Set italics format the current selection */ FormatItalics = 'KeyBinding.toggleItalicsInComposer', + /** Insert link for current selection */ + FormatLink = 'KeyBinding.FormatLink', + /** Set code format for current selection */ + FormatCode = 'KeyBinding.FormatCode', /** Format the current selection as quote */ FormatQuote = 'KeyBinding.toggleQuoteInComposer', /** Undo the last editing */ @@ -164,7 +168,7 @@ export const KEY_ICON: Record = { }; if (isMac) { KEY_ICON[Key.META] = "⌘"; - KEY_ICON[Key.SHIFT] = "⌥"; + KEY_ICON[Key.ALT] = "⌥"; } export const CATEGORIES: Record = { @@ -176,6 +180,8 @@ export const CATEGORIES: Record = { KeyBindingAction.FormatBold, KeyBindingAction.FormatItalics, KeyBindingAction.FormatQuote, + KeyBindingAction.FormatLink, + KeyBindingAction.FormatCode, KeyBindingAction.EditUndo, KeyBindingAction.EditRedo, KeyBindingAction.MoveCursorToStart, @@ -273,6 +279,21 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, displayName: _td("Toggle Quote"), }, + [KeyBindingAction.FormatCode]: { + default: { + ctrlOrCmdKey: true, + key: Key.E, + }, + displayName: _td("Toggle Code Block"), + }, + [KeyBindingAction.FormatLink]: { + default: { + ctrlOrCmdKey: true, + shiftKey: true, + key: Key.L, + }, + displayName: _td("Toggle Link"), + }, [KeyBindingAction.CancelReplyOrEdit]: { default: { key: Key.ESCAPE, diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 44fa879b84c..2f27e2613a2 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -24,13 +24,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; import { Caret, setSelection } from '../../../editor/caret'; -import { - formatRangeAsQuote, - formatRangeAsCode, - toggleInlineFormat, - replaceRangeAndMoveCaret, - formatRangeAsLink, -} from '../../../editor/operations'; +import { formatRange, replaceRangeAndMoveCaret, toggleInlineFormat } from '../../../editor/operations'; import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom'; import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; import { getAutoCompleteCreator, Type } from '../../../editor/parts'; @@ -46,7 +40,7 @@ import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar import DocumentOffset from "../../../editor/offset"; import { IDiff } from "../../../editor/diff"; import AutocompleteWrapperModel from "../../../editor/autocomplete"; -import DocumentPosition from "../../../editor/position"; +import DocumentPosition from '../../../editor/position'; import { ICompletion } from "../../../autocomplete/Autocompleter"; import { getKeyBindingsManager } from '../../../KeyBindingsManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -67,8 +61,11 @@ const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([ ["<", ">"], ]); -function ctrlShortcutLabel(key: string): string { - return (IS_MAC ? "⌘" : _t(ALTERNATE_KEY_NAME[Key.CONTROL])) + "+" + key; +function ctrlShortcutLabel(key: string, needsShift = false, needsAlt = false): string { + return (IS_MAC ? "⌘" : _t(ALTERNATE_KEY_NAME[Key.CONTROL])) + + (needsShift ? ("+" + _t(ALTERNATE_KEY_NAME[Key.SHIFT])) : "") + + (needsAlt ? ("+" + _t(ALTERNATE_KEY_NAME[Key.ALT])) : "") + + "+" + key; } function cloneSelection(selection: Selection): Partial { @@ -529,10 +526,18 @@ export default class BasicMessageEditor extends React.Component this.onFormatAction(Formatting.Italics); handled = true; break; + case KeyBindingAction.FormatCode: + this.onFormatAction(Formatting.Code); + handled = true; + break; case KeyBindingAction.FormatQuote: this.onFormatAction(Formatting.Quote); handled = true; break; + case KeyBindingAction.FormatLink: + this.onFormatAction(Formatting.InsertLink); + handled = true; + break; case KeyBindingAction.EditRedo: if (this.historyManager.canRedo()) { const { parts, caret } = this.historyManager.redo(); @@ -689,37 +694,13 @@ export default class BasicMessageEditor extends React.Component return caretPosition; } - private onFormatAction = (action: Formatting): void => { - const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); - // trim the range as we want it to exclude leading/trailing spaces - range.trim(); - - if (range.length === 0) { - return; - } + public onFormatAction = (action: Formatting): void => { + const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; - switch (action) { - case Formatting.Bold: - toggleInlineFormat(range, "**"); - break; - case Formatting.Italics: - toggleInlineFormat(range, "_"); - break; - case Formatting.Strikethrough: - toggleInlineFormat(range, "", ""); - break; - case Formatting.Code: - formatRangeAsCode(range); - break; - case Formatting.Quote: - formatRangeAsQuote(range); - break; - case Formatting.InsertLink: - formatRangeAsLink(range); - break; - } + + formatRange(range, action); }; render() { @@ -749,7 +730,9 @@ export default class BasicMessageEditor extends React.Component const shortcuts = { [Formatting.Bold]: ctrlShortcutLabel("B"), [Formatting.Italics]: ctrlShortcutLabel("I"), + [Formatting.Code]: ctrlShortcutLabel("E"), [Formatting.Quote]: ctrlShortcutLabel(">"), + [Formatting.InsertLink]: ctrlShortcutLabel("L", true), }; const { completionIndex } = this.state; diff --git a/src/components/views/rooms/MessageComposerFormatBar.tsx b/src/components/views/rooms/MessageComposerFormatBar.tsx index 37164502398..3f80bf1842a 100644 --- a/src/components/views/rooms/MessageComposerFormatBar.tsx +++ b/src/components/views/rooms/MessageComposerFormatBar.tsx @@ -56,9 +56,9 @@ export default class MessageComposerFormatBar extends React.PureComponent this.props.onAction(Formatting.Bold)} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} /> this.props.onAction(Formatting.Italics)} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} /> this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} /> - this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} /> + this.props.onAction(Formatting.Code)} icon="Code" shortcut={this.props.shortcuts.code} visible={this.state.visible} /> this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} /> - this.props.onAction(Formatting.InsertLink)} icon="InsertLink" visible={this.state.visible} /> + this.props.onAction(Formatting.InsertLink)} icon="InsertLink" shortcut={this.props.shortcuts.insert_link} visible={this.state.visible} /> ); } diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 85c0b783aa1..f8d4a7b2c4c 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -16,11 +16,54 @@ limitations under the License. import Range from "./range"; import { Part, Type } from "./parts"; +import { Formatting } from "../components/views/rooms/MessageComposerFormatBar"; /** * Some common queries and transformations on the editor model */ +/** + * Formats a given range with a given action + * @param {Range} range the range that should be formatted + * @param {Formatting} action the action that should be performed on the range + */ +export function formatRange(range: Range, action: Formatting): void { + // If the selection was empty we select the current word instead + if (range.wasInitializedEmpty()) { + selectRangeOfWordAtCaret(range); + } else { + // Remove whitespace or new lines in our selection + range.trim(); + } + + // Edgecase when just selecting whitespace or new line. + // There should be no reason to format whitespace, so we can just return. + if (range.length === 0) { + return; + } + + switch (action) { + case Formatting.Bold: + toggleInlineFormat(range, "**"); + break; + case Formatting.Italics: + toggleInlineFormat(range, "_"); + break; + case Formatting.Strikethrough: + toggleInlineFormat(range, "", ""); + break; + case Formatting.Code: + formatRangeAsCode(range); + break; + case Formatting.Quote: + formatRangeAsQuote(range); + break; + case Formatting.InsertLink: + formatRangeAsLink(range); + break; + } +} + export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void { const { model } = range; model.transform(() => { @@ -32,17 +75,69 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): }); } -export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0): void { +export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0, atNodeEnd = false): void { const { model } = range; model.transform(() => { const oldLen = range.length; const addedLen = range.replace(newParts); const firstOffset = range.start.asOffset(model); - const lastOffset = firstOffset.add(oldLen + addedLen + offset); + const lastOffset = firstOffset.add(oldLen + addedLen + offset, atNodeEnd); return lastOffset.asPosition(model); }); } +/** + * Replaces a range with formatting or removes existing formatting and + * positions the cursor with respect to the prefix and suffix length. + * @param {Range} range the previous value + * @param {Part[]} newParts the new value + * @param {boolean} rangeHasFormatting the new value + * @param {number} prefixLength the length of the formatting prefix + * @param {number} suffixLength the length of the formatting suffix, defaults to prefix length + */ +export function replaceRangeAndAutoAdjustCaret( + range: Range, + newParts: Part[], + rangeHasFormatting = false, + prefixLength: number, + suffixLength = prefixLength, +): void { + const { model } = range; + const lastStartingPosition = range.getLastStartingPosition(); + const relativeOffset = lastStartingPosition.offset - range.start.offset; + const distanceFromEnd = range.length - relativeOffset; + // Handle edge case where the caret is located within the suffix or prefix + if (rangeHasFormatting) { + if (relativeOffset < prefixLength) { // Was the caret at the left format string? + replaceRangeAndMoveCaret(range, newParts, -(range.length - 2 * suffixLength)); + return; + } + if (distanceFromEnd < suffixLength) { // Was the caret at the right format string? + replaceRangeAndMoveCaret(range, newParts, 0, true); + return; + } + } + // Calculate new position with respect to the previous position + model.transform(() => { + const offsetDirection = Math.sign(range.replace(newParts)); // Compensates for shrinkage or expansion + const atEnd = distanceFromEnd === suffixLength; + return lastStartingPosition.asOffset(model).add(offsetDirection * prefixLength, atEnd).asPosition(model); + }); +} + +const isFormattable = (_index: number, offset: number, part: Part) => { + return part.text[offset] !== " " && part.type === Type.Plain; +}; + +export function selectRangeOfWordAtCaret(range: Range): void { + // Select right side of word + range.expandForwardsWhile(isFormattable); + // Select left side of word + range.expandBackwardsWhile(isFormattable); + // Trim possibly selected new lines + range.trim(); +} + export function rangeStartsAtBeginningOfLine(range: Range): boolean { const { model } = range; const startsWithPartial = range.start.offset !== 0; @@ -76,7 +171,6 @@ export function formatRangeAsQuote(range: Range): void { if (!rangeEndsAtEndOfLine(range)) { parts.push(partCreator.newline()); } - parts.push(partCreator.newline()); replaceRangeAndExpandSelection(range, parts); } @@ -84,8 +178,22 @@ export function formatRangeAsQuote(range: Range): void { export function formatRangeAsCode(range: Range): void { const { model, parts } = range; const { partCreator } = model; - const needsBlock = parts.some(p => p.type === Type.Newline); - if (needsBlock) { + + const hasBlockFormatting = (range.length > 0) + && range.text.startsWith("```") + && range.text.endsWith("```"); + + 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") { + parts.shift(); + parts.pop(); + } + } else if (needsBlockFormatting) { parts.unshift(partCreator.plain("```"), partCreator.newline()); if (!rangeStartsAtBeginningOfLine(range)) { parts.unshift(partCreator.newline()); @@ -97,19 +205,28 @@ export function formatRangeAsCode(range: Range): void { parts.push(partCreator.newline()); } } else { - parts.unshift(partCreator.plain("`")); - parts.push(partCreator.plain("`")); + toggleInlineFormat(range, "`"); + return; } + replaceRangeAndExpandSelection(range, parts); } export function formatRangeAsLink(range: Range) { - const { model, parts } = range; + const { model } = range; const { partCreator } = model; - parts.unshift(partCreator.plain("[")); - parts.push(partCreator.plain("]()")); - // We set offset to -1 here so that the caret lands between the brackets - replaceRangeAndMoveCaret(range, parts, -1); + const linkRegex = /\[(.*?)\]\(.*?\)/g; + const isFormattedAsLink = linkRegex.test(range.text); + if (isFormattedAsLink) { + const linkDescription = range.text.replace(linkRegex, "$1"); + const newParts = [partCreator.plain(linkDescription)]; + const prefixLength = 1; + const suffixLength = range.length - (linkDescription.length + 2); + replaceRangeAndAutoAdjustCaret(range, newParts, true, prefixLength, suffixLength); + } else { + // We set offset to -1 here so that the caret lands between the brackets + replaceRangeAndMoveCaret(range, [partCreator.plain("[" + range.text + "]" + "()")], -1); + } } // parts helper methods @@ -162,7 +279,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix parts[index - 1].text.endsWith(suffix); if (isFormatted) { - // remove prefix and suffix + // remove prefix and suffix formatting string const partWithoutPrefix = parts[base].serialize(); partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length); parts[base] = partCreator.deserializePart(partWithoutPrefix); @@ -178,5 +295,13 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix } }); - replaceRangeAndExpandSelection(range, parts); + // If the user didn't select something initially, we want to just restore + // the caret position instead of making a new selection. + if (range.wasInitializedEmpty() && prefix === suffix) { + // Check if we need to add a offset for a toggle or untoggle + const hasFormatting = range.text.startsWith(prefix) && range.text.endsWith(suffix); + replaceRangeAndAutoAdjustCaret(range, parts, hasFormatting, prefix.length); + } else { + replaceRangeAndExpandSelection(range, parts); + } } diff --git a/src/editor/range.ts b/src/editor/range.ts index 4336a151306..fc42fd186ed 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -25,11 +25,15 @@ const whitespacePredicate: Predicate = (index, offset, part) => { export default class Range { private _start: DocumentPosition; private _end: DocumentPosition; + private _lastStart: DocumentPosition; + private _initializedEmpty: boolean; constructor(public readonly model: EditorModel, positionA: DocumentPosition, positionB = positionA) { const bIsLarger = positionA.compare(positionB) < 0; this._start = bIsLarger ? positionA : positionB; this._end = bIsLarger ? positionB : positionA; + this._lastStart = this._start; + this._initializedEmpty = this._start.index === this._end.index && this._start.offset == this._end.offset; } public moveStartForwards(delta: number): void { @@ -39,6 +43,22 @@ export default class Range { }); } + public wasInitializedEmpty(): boolean { + return this._initializedEmpty; + } + + public setWasEmpty(value: boolean) { + this._initializedEmpty = value; + } + + public getLastStartingPosition(): DocumentPosition { + return this._lastStart; + } + + public setLastStartingPosition(position: DocumentPosition): void { + this._lastStart = position; + } + public moveEndBackwards(delta: number): void { this._end = this._end.backwardsWhile(this.model, () => { delta -= 1; @@ -47,6 +67,10 @@ export default class Range { } public trim(): void { + if (this.text.trim() === "") { + this._start = this._end; + return; + } this._start = this._start.forwardsWhile(this.model, whitespacePredicate); this._end = this._end.backwardsWhile(this.model, whitespacePredicate); } @@ -55,6 +79,10 @@ export default class Range { this._start = this._start.backwardsWhile(this.model, predicate); } + public expandForwardsWhile(predicate: Predicate): void { + this._end = this._end.forwardsWhile(this.model, predicate); + } + public get text(): string { let text = ""; this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 997bebff288..f388b0186b3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3397,6 +3397,8 @@ "Toggle Bold": "Toggle Bold", "Toggle Italics": "Toggle Italics", "Toggle Quote": "Toggle Quote", + "Toggle Code Block": "Toggle Code Block", + "Toggle Link": "Toggle Link", "Cancel replying to a message": "Cancel replying to a message", "Navigate to next message to edit": "Navigate to next message to edit", "Navigate to previous message to edit": "Navigate to previous message to edit", diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index 17a4c8ba118..ddf088a9bc0 100644 --- a/test/editor/operations-test.js +++ b/test/editor/operations-test.js @@ -17,7 +17,12 @@ limitations under the License. import "../skinned-sdk"; // Must be first for skinning to work import EditorModel from "../../src/editor/model"; import { createPartCreator, createRenderer } from "./mock"; -import { toggleInlineFormat } from "../../src/editor/operations"; +import { + toggleInlineFormat, + selectRangeOfWordAtCaret, + formatRange, +} from "../../src/editor/operations"; +import { Formatting } from "../../src/components/views/rooms/MessageComposerFormatBar"; const SERIALIZED_NEWLINE = { "text": "\n", "type": "newline" }; @@ -35,7 +40,7 @@ describe('editor/operations: formatting operations', () => { expect(range.parts[0].text).toBe("world"); expect(model.serializeParts()).toEqual([{ "text": "hello world!", "type": "plain" }]); - toggleInlineFormat(range, "_"); + formatRange(range, Formatting.Italics); expect(model.serializeParts()).toEqual([{ "text": "hello _world_!", "type": "plain" }]); }); @@ -73,7 +78,7 @@ describe('editor/operations: formatting operations', () => { { "text": "@room", "type": "at-room-pill" }, { "text": ", how are you doing?", "type": "plain" }, ]); - toggleInlineFormat(range, "_"); + formatRange(range, Formatting.Italics); expect(model.serializeParts()).toEqual([ { "text": "hello _there ", "type": "plain" }, { "text": "@room", "type": "at-room-pill" }, @@ -99,7 +104,7 @@ describe('editor/operations: formatting operations', () => { SERIALIZED_NEWLINE, { "text": "how are you doing?", "type": "plain" }, ]); - toggleInlineFormat(range, "**"); + formatRange(range, Formatting.Bold); expect(model.serializeParts()).toEqual([ { "text": "hello **world,", "type": "plain" }, SERIALIZED_NEWLINE, @@ -132,7 +137,7 @@ describe('editor/operations: formatting operations', () => { SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, ]); - toggleInlineFormat(range, "**"); + formatRange(range, Formatting.Bold); expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, @@ -187,5 +192,192 @@ describe('editor/operations: formatting operations', () => { { "text": "new paragraph", "type": "plain" }, ]); }); + + it('format word at caret position at beginning of new line without previous selection', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.newline(), + pc.plain("hello!"), + ], pc, renderer); + + let range = model.startRange(model.positionForOffset(1, false)); + + // Initial position should equal start and end since we did not select anything + expect(range.getLastStartingPosition()).toBe(range.start); + expect(range.getLastStartingPosition()).toBe(range.end); + + formatRange(range, Formatting.Bold); // Toggle + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "**hello!**", "type": "plain" }, + ]); + + formatRange(range, Formatting.Bold); // Untoggle + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "hello!", "type": "plain" }, + ]); + + // Check if it also works for code as it uses toggleInlineFormatting only indirectly + range = model.startRange(model.positionForOffset(1, false)); + selectRangeOfWordAtCaret(range); + + formatRange(range, Formatting.Code); // Toggle + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "`hello!`", "type": "plain" }, + ]); + + formatRange(range, Formatting.Code); // Untoggle + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "hello!", "type": "plain" }, + ]); + }); + + it('caret resets correctly to current line when untoggling formatting while caret at line end', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello **hello!**"), + pc.newline(), + pc.plain("world"), + ], pc, renderer); + + expect(model.serializeParts()).toEqual([ + { "text": "hello **hello!**", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "world", "type": "plain" }, + ]); + + const endOfFirstLine = 16; + const range = model.startRange(model.positionForOffset(endOfFirstLine, true)); + + formatRange(range, Formatting.Bold); // Untoggle + formatRange(range, Formatting.Italics); // Toggle + + // We expect formatting to still happen in the first line as the caret should not jump down + expect(model.serializeParts()).toEqual([ + { "text": "hello _hello!_", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "world", "type": "plain" }, + ]); + }); + + it('format link in front of new line part', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello!"), + pc.newline(), + pc.plain("world!"), + pc.newline(), + ], pc, renderer); + + let range = model.startRange(model.getPositionAtEnd().asOffset(model).add(-1).asPosition(model)); // select-all + + expect(model.serializeParts()).toEqual([ + { "text": "hello!", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "world!", "type": "plain" }, + SERIALIZED_NEWLINE, + ]); + + formatRange(range, Formatting.InsertLink); // Toggle + expect(model.serializeParts()).toEqual([ + { "text": "hello!", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "[world!]()", "type": "plain" }, + SERIALIZED_NEWLINE, + ]); + + range = model.startRange(model.getPositionAtEnd().asOffset(model).add(-1).asPosition(model)); // select-all + formatRange(range, Formatting.InsertLink); // Untoggle + expect(model.serializeParts()).toEqual([ + { "text": "hello!", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "world!", "type": "plain" }, + SERIALIZED_NEWLINE, + ]); + }); + + it('format multi line code', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("int x = 1;"), + pc.newline(), + pc.newline(), + pc.plain("int y = 42;"), + ], pc, renderer); + + let range = model.startRange(model.positionForOffset(0), model.getPositionAtEnd()); // select-all + + expect(range.parts.map(p => p.text).join("")).toBe("int x = 1;\n\nint y = 42;"); + + expect(model.serializeParts()).toEqual([ + { "text": "int x = 1;", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": "int y = 42;", "type": "plain" }, + ]); + + formatRange(range, Formatting.Code); // Toggle + + expect(model.serializeParts()).toEqual([ + { "text": "```", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "int x = 1;", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": "int y = 42;", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "```", "type": "plain" }, + ]); + + range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all + formatRange(range, Formatting.Code); // Untoggle + + expect(model.serializeParts()).toEqual([ + { "text": "int x = 1;", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": "int y = 42;", "type": "plain" }, + ]); + }); + + it('does not format pure white space', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain(" "), + pc.newline(), + pc.newline(), + pc.plain(" "), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(0), model.getPositionAtEnd()); // select-all + expect(range.parts.map(p => p.text).join("")).toBe(" \n\n "); + + expect(model.serializeParts()).toEqual([ + { "text": " ", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": " ", "type": "plain" }, + ]); + + formatRange(range, Formatting.Bold); + + expect(model.serializeParts()).toEqual([ + { "text": " ", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": " ", "type": "plain" }, + ]); + }); }); }); diff --git a/test/editor/range-test.js b/test/editor/range-test.js index 87c5b06e44f..42ec6de60dc 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -104,4 +104,21 @@ describe('editor/range', function() { range.trim(); expect(range.parts[0].text).toBe("abc"); }); + // test for edge case when the selection just consists of whitespace + it('range trim just whitespace', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const whitespace = " \n \n\n"; + const model = new EditorModel([ + pc.plain(whitespace), + ], pc, renderer); + const range = model.startRange( + model.positionForOffset(0, false), + model.getPositionAtEnd(), + ); + + expect(range.text).toBe(whitespace); + range.trim(); + expect(range.text).toBe(""); + }); });