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

Commit 26e6f8d

Browse files
Improve formatting features in the editor (#7104)
Co-authored-by: Michael Telatynski <[email protected]>
1 parent cbf5fbf commit 26e6f8d

File tree

8 files changed

+427
-59
lines changed

8 files changed

+427
-59
lines changed

src/accessibility/KeyboardShortcuts.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export enum KeyBindingAction {
4141
FormatBold = 'KeyBinding.toggleBoldInComposer',
4242
/** Set italics format the current selection */
4343
FormatItalics = 'KeyBinding.toggleItalicsInComposer',
44+
/** Insert link for current selection */
45+
FormatLink = 'KeyBinding.FormatLink',
46+
/** Set code format for current selection */
47+
FormatCode = 'KeyBinding.FormatCode',
4448
/** Format the current selection as quote */
4549
FormatQuote = 'KeyBinding.toggleQuoteInComposer',
4650
/** Undo the last editing */
@@ -210,6 +214,8 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
210214
KeyBindingAction.FormatBold,
211215
KeyBindingAction.FormatItalics,
212216
KeyBindingAction.FormatQuote,
217+
KeyBindingAction.FormatLink,
218+
KeyBindingAction.FormatCode,
213219
KeyBindingAction.EditUndo,
214220
KeyBindingAction.EditRedo,
215221
KeyBindingAction.MoveCursorToStart,
@@ -337,6 +343,21 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
337343
},
338344
displayName: _td("Toggle Quote"),
339345
},
346+
[KeyBindingAction.FormatCode]: {
347+
default: {
348+
ctrlOrCmdKey: true,
349+
key: Key.E,
350+
},
351+
displayName: _td("Toggle Code Block"),
352+
},
353+
[KeyBindingAction.FormatLink]: {
354+
default: {
355+
ctrlOrCmdKey: true,
356+
shiftKey: true,
357+
key: Key.L,
358+
},
359+
displayName: _td("Toggle Link"),
360+
},
340361
[KeyBindingAction.CancelReplyOrEdit]: {
341362
default: {
342363
key: Key.ESCAPE,

src/components/views/rooms/BasicMessageComposer.tsx

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,7 @@ import { logger } from "matrix-js-sdk/src/logger";
2424
import EditorModel from '../../../editor/model';
2525
import HistoryManager from '../../../editor/history';
2626
import { Caret, setSelection } from '../../../editor/caret';
27-
import {
28-
formatRangeAsQuote,
29-
formatRangeAsCode,
30-
toggleInlineFormat,
31-
replaceRangeAndMoveCaret,
32-
formatRangeAsLink,
33-
} from '../../../editor/operations';
27+
import { formatRange, replaceRangeAndMoveCaret, toggleInlineFormat } from '../../../editor/operations';
3428
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
3529
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
3630
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
@@ -46,7 +40,7 @@ import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar
4640
import DocumentOffset from "../../../editor/offset";
4741
import { IDiff } from "../../../editor/diff";
4842
import AutocompleteWrapperModel from "../../../editor/autocomplete";
49-
import DocumentPosition from "../../../editor/position";
43+
import DocumentPosition from '../../../editor/position';
5044
import { ICompletion } from "../../../autocomplete/Autocompleter";
5145
import { getKeyBindingsManager } from '../../../KeyBindingsManager';
5246
import { replaceableComponent } from "../../../utils/replaceableComponent";
@@ -67,8 +61,11 @@ const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
6761
["<", ">"],
6862
]);
6963

70-
function ctrlShortcutLabel(key: string): string {
71-
return (IS_MAC ? "⌘" : _t(ALTERNATE_KEY_NAME[Key.CONTROL])) + "+" + key;
64+
function ctrlShortcutLabel(key: string, needsShift = false, needsAlt = false): string {
65+
return (IS_MAC ? "⌘" : _t(ALTERNATE_KEY_NAME[Key.CONTROL])) +
66+
(needsShift ? ("+" + _t(ALTERNATE_KEY_NAME[Key.SHIFT])) : "") +
67+
(needsAlt ? ("+" + _t(ALTERNATE_KEY_NAME[Key.ALT])) : "") +
68+
"+" + key;
7269
}
7370

7471
function cloneSelection(selection: Selection): Partial<Selection> {
@@ -530,10 +527,18 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
530527
this.onFormatAction(Formatting.Italics);
531528
handled = true;
532529
break;
530+
case KeyBindingAction.FormatCode:
531+
this.onFormatAction(Formatting.Code);
532+
handled = true;
533+
break;
533534
case KeyBindingAction.FormatQuote:
534535
this.onFormatAction(Formatting.Quote);
535536
handled = true;
536537
break;
538+
case KeyBindingAction.FormatLink:
539+
this.onFormatAction(Formatting.InsertLink);
540+
handled = true;
541+
break;
537542
case KeyBindingAction.EditRedo:
538543
if (this.historyManager.canRedo()) {
539544
const { parts, caret } = this.historyManager.redo();
@@ -690,37 +695,13 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
690695
return caretPosition;
691696
}
692697

693-
private onFormatAction = (action: Formatting): void => {
694-
const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
695-
// trim the range as we want it to exclude leading/trailing spaces
696-
range.trim();
697-
698-
if (range.length === 0) {
699-
return;
700-
}
698+
public onFormatAction = (action: Formatting): void => {
699+
const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
701700

702701
this.historyManager.ensureLastChangesPushed(this.props.model);
703702
this.modifiedFlag = true;
704-
switch (action) {
705-
case Formatting.Bold:
706-
toggleInlineFormat(range, "**");
707-
break;
708-
case Formatting.Italics:
709-
toggleInlineFormat(range, "_");
710-
break;
711-
case Formatting.Strikethrough:
712-
toggleInlineFormat(range, "<del>", "</del>");
713-
break;
714-
case Formatting.Code:
715-
formatRangeAsCode(range);
716-
break;
717-
case Formatting.Quote:
718-
formatRangeAsQuote(range);
719-
break;
720-
case Formatting.InsertLink:
721-
formatRangeAsLink(range);
722-
break;
723-
}
703+
704+
formatRange(range, action);
724705
};
725706

726707
render() {
@@ -750,7 +731,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
750731
const shortcuts = {
751732
[Formatting.Bold]: ctrlShortcutLabel("B"),
752733
[Formatting.Italics]: ctrlShortcutLabel("I"),
734+
[Formatting.Code]: ctrlShortcutLabel("E"),
753735
[Formatting.Quote]: ctrlShortcutLabel(">"),
736+
[Formatting.InsertLink]: ctrlShortcutLabel("L", true),
754737
};
755738

756739
const { completionIndex } = this.state;

src/components/views/rooms/MessageComposerFormatBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ export default class MessageComposerFormatBar extends React.PureComponent<IProps
5656
<FormatButton label={_t("Bold")} onClick={() => this.props.onAction(Formatting.Bold)} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} />
5757
<FormatButton label={_t("Italics")} onClick={() => this.props.onAction(Formatting.Italics)} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} />
5858
<FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} />
59-
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} />
59+
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" shortcut={this.props.shortcuts.code} visible={this.state.visible} />
6060
<FormatButton label={_t("Quote")} onClick={() => this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
61-
<FormatButton label={_t("Insert link")} onClick={() => this.props.onAction(Formatting.InsertLink)} icon="InsertLink" visible={this.state.visible} />
61+
<FormatButton label={_t("Insert link")} onClick={() => this.props.onAction(Formatting.InsertLink)} icon="InsertLink" shortcut={this.props.shortcuts.insert_link} visible={this.state.visible} />
6262
</div>);
6363
}
6464

src/editor/operations.ts

Lines changed: 139 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,54 @@ limitations under the License.
1616

1717
import Range from "./range";
1818
import { Part, Type } from "./parts";
19+
import { Formatting } from "../components/views/rooms/MessageComposerFormatBar";
1920

2021
/**
2122
* Some common queries and transformations on the editor model
2223
*/
2324

25+
/**
26+
* Formats a given range with a given action
27+
* @param {Range} range the range that should be formatted
28+
* @param {Formatting} action the action that should be performed on the range
29+
*/
30+
export function formatRange(range: Range, action: Formatting): void {
31+
// If the selection was empty we select the current word instead
32+
if (range.wasInitializedEmpty()) {
33+
selectRangeOfWordAtCaret(range);
34+
} else {
35+
// Remove whitespace or new lines in our selection
36+
range.trim();
37+
}
38+
39+
// Edgecase when just selecting whitespace or new line.
40+
// There should be no reason to format whitespace, so we can just return.
41+
if (range.length === 0) {
42+
return;
43+
}
44+
45+
switch (action) {
46+
case Formatting.Bold:
47+
toggleInlineFormat(range, "**");
48+
break;
49+
case Formatting.Italics:
50+
toggleInlineFormat(range, "_");
51+
break;
52+
case Formatting.Strikethrough:
53+
toggleInlineFormat(range, "<del>", "</del>");
54+
break;
55+
case Formatting.Code:
56+
formatRangeAsCode(range);
57+
break;
58+
case Formatting.Quote:
59+
formatRangeAsQuote(range);
60+
break;
61+
case Formatting.InsertLink:
62+
formatRangeAsLink(range);
63+
break;
64+
}
65+
}
66+
2467
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void {
2568
const { model } = range;
2669
model.transform(() => {
@@ -32,17 +75,69 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]):
3275
});
3376
}
3477

35-
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0): void {
78+
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0, atNodeEnd = false): void {
3679
const { model } = range;
3780
model.transform(() => {
3881
const oldLen = range.length;
3982
const addedLen = range.replace(newParts);
4083
const firstOffset = range.start.asOffset(model);
41-
const lastOffset = firstOffset.add(oldLen + addedLen + offset);
84+
const lastOffset = firstOffset.add(oldLen + addedLen + offset, atNodeEnd);
4285
return lastOffset.asPosition(model);
4386
});
4487
}
4588

89+
/**
90+
* Replaces a range with formatting or removes existing formatting and
91+
* positions the cursor with respect to the prefix and suffix length.
92+
* @param {Range} range the previous value
93+
* @param {Part[]} newParts the new value
94+
* @param {boolean} rangeHasFormatting the new value
95+
* @param {number} prefixLength the length of the formatting prefix
96+
* @param {number} suffixLength the length of the formatting suffix, defaults to prefix length
97+
*/
98+
export function replaceRangeAndAutoAdjustCaret(
99+
range: Range,
100+
newParts: Part[],
101+
rangeHasFormatting = false,
102+
prefixLength: number,
103+
suffixLength = prefixLength,
104+
): void {
105+
const { model } = range;
106+
const lastStartingPosition = range.getLastStartingPosition();
107+
const relativeOffset = lastStartingPosition.offset - range.start.offset;
108+
const distanceFromEnd = range.length - relativeOffset;
109+
// Handle edge case where the caret is located within the suffix or prefix
110+
if (rangeHasFormatting) {
111+
if (relativeOffset < prefixLength) { // Was the caret at the left format string?
112+
replaceRangeAndMoveCaret(range, newParts, -(range.length - 2 * suffixLength));
113+
return;
114+
}
115+
if (distanceFromEnd < suffixLength) { // Was the caret at the right format string?
116+
replaceRangeAndMoveCaret(range, newParts, 0, true);
117+
return;
118+
}
119+
}
120+
// Calculate new position with respect to the previous position
121+
model.transform(() => {
122+
const offsetDirection = Math.sign(range.replace(newParts)); // Compensates for shrinkage or expansion
123+
const atEnd = distanceFromEnd === suffixLength;
124+
return lastStartingPosition.asOffset(model).add(offsetDirection * prefixLength, atEnd).asPosition(model);
125+
});
126+
}
127+
128+
const isFormattable = (_index: number, offset: number, part: Part) => {
129+
return part.text[offset] !== " " && part.type === Type.Plain;
130+
};
131+
132+
export function selectRangeOfWordAtCaret(range: Range): void {
133+
// Select right side of word
134+
range.expandForwardsWhile(isFormattable);
135+
// Select left side of word
136+
range.expandBackwardsWhile(isFormattable);
137+
// Trim possibly selected new lines
138+
range.trim();
139+
}
140+
46141
export function rangeStartsAtBeginningOfLine(range: Range): boolean {
47142
const { model } = range;
48143
const startsWithPartial = range.start.offset !== 0;
@@ -76,16 +171,29 @@ export function formatRangeAsQuote(range: Range): void {
76171
if (!rangeEndsAtEndOfLine(range)) {
77172
parts.push(partCreator.newline());
78173
}
79-
80174
parts.push(partCreator.newline());
81175
replaceRangeAndExpandSelection(range, parts);
82176
}
83177

84178
export function formatRangeAsCode(range: Range): void {
85179
const { model, parts } = range;
86180
const { partCreator } = model;
87-
const needsBlock = parts.some(p => p.type === Type.Newline);
88-
if (needsBlock) {
181+
182+
const hasBlockFormatting = (range.length > 0)
183+
&& range.text.startsWith("```")
184+
&& range.text.endsWith("```");
185+
186+
const needsBlockFormatting = parts.some(p => p.type === Type.Newline);
187+
188+
if (hasBlockFormatting) {
189+
// Remove previously pushed backticks and new lines
190+
parts.shift();
191+
parts.pop();
192+
if (parts[0]?.text === "\n" && parts[parts.length - 1]?.text === "\n") {
193+
parts.shift();
194+
parts.pop();
195+
}
196+
} else if (needsBlockFormatting) {
89197
parts.unshift(partCreator.plain("```"), partCreator.newline());
90198
if (!rangeStartsAtBeginningOfLine(range)) {
91199
parts.unshift(partCreator.newline());
@@ -97,19 +205,28 @@ export function formatRangeAsCode(range: Range): void {
97205
parts.push(partCreator.newline());
98206
}
99207
} else {
100-
parts.unshift(partCreator.plain("`"));
101-
parts.push(partCreator.plain("`"));
208+
toggleInlineFormat(range, "`");
209+
return;
102210
}
211+
103212
replaceRangeAndExpandSelection(range, parts);
104213
}
105214

106215
export function formatRangeAsLink(range: Range) {
107-
const { model, parts } = range;
216+
const { model } = range;
108217
const { partCreator } = model;
109-
parts.unshift(partCreator.plain("["));
110-
parts.push(partCreator.plain("]()"));
111-
// We set offset to -1 here so that the caret lands between the brackets
112-
replaceRangeAndMoveCaret(range, parts, -1);
218+
const linkRegex = /\[(.*?)\]\(.*?\)/g;
219+
const isFormattedAsLink = linkRegex.test(range.text);
220+
if (isFormattedAsLink) {
221+
const linkDescription = range.text.replace(linkRegex, "$1");
222+
const newParts = [partCreator.plain(linkDescription)];
223+
const prefixLength = 1;
224+
const suffixLength = range.length - (linkDescription.length + 2);
225+
replaceRangeAndAutoAdjustCaret(range, newParts, true, prefixLength, suffixLength);
226+
} else {
227+
// We set offset to -1 here so that the caret lands between the brackets
228+
replaceRangeAndMoveCaret(range, [partCreator.plain("[" + range.text + "]" + "()")], -1);
229+
}
113230
}
114231

115232
// parts helper methods
@@ -162,7 +279,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix
162279
parts[index - 1].text.endsWith(suffix);
163280

164281
if (isFormatted) {
165-
// remove prefix and suffix
282+
// remove prefix and suffix formatting string
166283
const partWithoutPrefix = parts[base].serialize();
167284
partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length);
168285
parts[base] = partCreator.deserializePart(partWithoutPrefix);
@@ -178,5 +295,13 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix
178295
}
179296
});
180297

181-
replaceRangeAndExpandSelection(range, parts);
298+
// If the user didn't select something initially, we want to just restore
299+
// the caret position instead of making a new selection.
300+
if (range.wasInitializedEmpty() && prefix === suffix) {
301+
// Check if we need to add a offset for a toggle or untoggle
302+
const hasFormatting = range.text.startsWith(prefix) && range.text.endsWith(suffix);
303+
replaceRangeAndAutoAdjustCaret(range, parts, hasFormatting, prefix.length);
304+
} else {
305+
replaceRangeAndExpandSelection(range, parts);
306+
}
182307
}

0 commit comments

Comments
 (0)