mirror of https://github.com/vector-im/riot-web
Improve formatting features in the editor (#7104)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>pull/21833/head
parent
cbf5fbf870
commit
26e6f8deca
|
@ -41,6 +41,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 */
|
||||
|
@ -210,6 +214,8 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
|
|||
KeyBindingAction.FormatBold,
|
||||
KeyBindingAction.FormatItalics,
|
||||
KeyBindingAction.FormatQuote,
|
||||
KeyBindingAction.FormatLink,
|
||||
KeyBindingAction.FormatCode,
|
||||
KeyBindingAction.EditUndo,
|
||||
KeyBindingAction.EditRedo,
|
||||
KeyBindingAction.MoveCursorToStart,
|
||||
|
@ -337,6 +343,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,
|
||||
|
|
|
@ -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<Selection> {
|
||||
|
@ -530,10 +527,18 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
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();
|
||||
|
@ -690,37 +695,13 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
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, "<del>", "</del>");
|
||||
break;
|
||||
case Formatting.Code:
|
||||
formatRangeAsCode(range);
|
||||
break;
|
||||
case Formatting.Quote:
|
||||
formatRangeAsQuote(range);
|
||||
break;
|
||||
case Formatting.InsertLink:
|
||||
formatRangeAsLink(range);
|
||||
break;
|
||||
}
|
||||
|
||||
formatRange(range, action);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -750,7 +731,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
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;
|
||||
|
|
|
@ -56,9 +56,9 @@ export default class MessageComposerFormatBar extends React.PureComponent<IProps
|
|||
<FormatButton label={_t("Bold")} onClick={() => this.props.onAction(Formatting.Bold)} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} />
|
||||
<FormatButton label={_t("Italics")} onClick={() => this.props.onAction(Formatting.Italics)} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} />
|
||||
<FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} />
|
||||
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} />
|
||||
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" shortcut={this.props.shortcuts.code} visible={this.state.visible} />
|
||||
<FormatButton label={_t("Quote")} onClick={() => this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
|
||||
<FormatButton label={_t("Insert link")} onClick={() => this.props.onAction(Formatting.InsertLink)} icon="InsertLink" visible={this.state.visible} />
|
||||
<FormatButton label={_t("Insert link")} onClick={() => this.props.onAction(Formatting.InsertLink)} icon="InsertLink" shortcut={this.props.shortcuts.insert_link} visible={this.state.visible} />
|
||||
</div>);
|
||||
}
|
||||
|
||||
|
|
|
@ -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, "<del>", "</del>");
|
||||
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("]()"));
|
||||
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, parts, -1);
|
||||
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
|
|||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -3430,6 +3430,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",
|
||||
|
|
|
@ -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,
|
||||
|
@ -186,5 +191,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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue