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

Improve formatting features in the editor #7104

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d17f2b6
Add helper method
alexanderstephan Oct 29, 2021
d5a145d
Add shortcut reference
alexanderstephan Oct 29, 2021
ccc1112
Add shortcut logic
alexanderstephan Oct 29, 2021
cf1fd8b
Add keybindings
alexanderstephan Oct 29, 2021
8cb6355
Add links and code format to MessageComposerAction
alexanderstephan Oct 29, 2021
4ece17e
Small fixes and refactoring
alexanderstephan Oct 30, 2021
8bbfdf0
Fix naming logic
alexanderstephan Oct 30, 2021
efee3fc
Improve signature
alexanderstephan Oct 30, 2021
5b1b0b6
Add toggle function for code formatting
alexanderstephan Nov 1, 2021
1a56265
Merge branch 'matrix-org:develop' into alexanderstephan/linkshortcut
alexanderstephan Nov 1, 2021
617c686
Clean up naming logic and some refactoring
alexanderstephan Nov 1, 2021
a7f2816
Improve handling of whitespace
alexanderstephan Nov 1, 2021
e53f68d
Add more elaborate comment about whitespace handling
alexanderstephan Nov 1, 2021
f358be3
Fix trim
alexanderstephan Nov 1, 2021
e63ef6c
Cleaner, more robust multiline toggle
alexanderstephan Nov 1, 2021
9f726d8
Merge branch 'matrix-org:develop' into alexanderstephan/linkshortcut
alexanderstephan Nov 2, 2021
19f9527
Add caret reset for no selection
alexanderstephan Nov 3, 2021
35b1edc
Fix range condition and clean up
alexanderstephan Nov 4, 2021
6c185c1
Merge branch 'matrix-org:develop' into alexanderstephan/linkshortcut
alexanderstephan Nov 4, 2021
19a519d
Refactoring
alexanderstephan Nov 5, 2021
eb5fade
Linter: Remove empty line
alexanderstephan Nov 5, 2021
5f9817a
Extract format range to operations and add unit tests
alexanderstephan Nov 7, 2021
e07d1ba
Add pure whitespace handling to range
alexanderstephan Nov 8, 2021
684e0f5
Handle deletion of caret position
alexanderstephan Nov 8, 2021
bf84878
Add comments to important methods, improve names and clean up signatures
alexanderstephan Nov 8, 2021
ea04826
Merge branch 'matrix-org:develop' into alexanderstephan/linkshortcut
alexanderstephan Nov 8, 2021
d880a3e
Improve tests to enable better checking of selection position
alexanderstephan Nov 8, 2021
4f27d41
Respect punctuation
alexanderstephan Nov 9, 2021
9cb8ebe
Revert to substr
alexanderstephan Nov 9, 2021
1f5b24c
Allow punctuation again for consistency
alexanderstephan Nov 9, 2021
2c05a61
Remove whitespace
alexanderstephan Nov 9, 2021
abc9134
Add more comments and introduce constant for white space
alexanderstephan Nov 9, 2021
75822e7
Extract function for isPlainWord
alexanderstephan Nov 9, 2021
7fd0c64
Switch keybinding for macOS
alexanderstephan Nov 26, 2021
0137fa4
Fix equals
alexanderstephan Nov 26, 2021
22d178e
Merge branch 'matrix-org:develop' into alexanderstephan/linkshortcut
alexanderstephan Nov 26, 2021
4f40a1e
Fix missing ctrlOrCmd
alexanderstephan Nov 26, 2021
f4c545b
Use alternative shortcut for now
alexanderstephan Nov 27, 2021
15dd865
Enable link untoggle
alexanderstephan Nov 27, 2021
1e96f13
Update tests for different caret adjustment method
alexanderstephan Nov 27, 2021
a25643d
Merge branch 'matrix-org:develop' into alexanderstephan/linkshortcut
alexanderstephan Dec 22, 2021
5b45642
Merge branch 'matrix-org:develop' into alexanderstephan/linkshortcut
alexanderstephan Dec 27, 2021
8fa3406
Fix signatures and remove auto selection from quote
alexanderstephan Dec 27, 2021
2093e08
Fix comment style
alexanderstephan Dec 27, 2021
1e131b9
Fix equals
alexanderstephan Jan 6, 2022
e5d5eed
Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into…
t3chguy Feb 18, 2022
fd0befb
Merge fixup, document shortcuts and fix mac-specific alt/shift mixup
t3chguy Feb 18, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/accessibility/KeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -164,7 +168,7 @@ export const KEY_ICON: Record<string, string> = {
};
if (isMac) {
KEY_ICON[Key.META] = "⌘";
KEY_ICON[Key.SHIFT] = "⌥";
KEY_ICON[Key.ALT] = "⌥";
}

export const CATEGORIES: Record<CategoryName, ICategory> = {
Expand All @@ -176,6 +180,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 @@ -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,
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 @@ -529,10 +526,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 @@ -689,37 +694,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 @@ -749,7 +730,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