Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Improve formatting features in the editor (#7104)
Browse files Browse the repository at this point in the history
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
  • Loading branch information
alexanderstephan and t3chguy committed Mar 16, 2022
1 parent cbf5fbf commit 26e6f8d
Show file tree
Hide file tree
Showing 8 changed files with 427 additions and 59 deletions.
21 changes: 21 additions & 0 deletions src/accessibility/KeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 21 additions & 38 deletions src/components/views/rooms/BasicMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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";
Expand All @@ -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> {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/components/views/rooms/MessageComposerFormatBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>);
}

Expand Down
153 changes: 139 additions & 14 deletions src/editor/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -76,16 +171,29 @@ export function formatRangeAsQuote(range: Range): void {
if (!rangeEndsAtEndOfLine(range)) {
parts.push(partCreator.newline());
}

parts.push(partCreator.newline());
replaceRangeAndExpandSelection(range, parts);
}

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());
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
Loading

0 comments on commit 26e6f8d

Please sign in to comment.