From d17f2b64872b7461e1eeb18f937dbf1c20908508 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 00:07:14 +0200 Subject: [PATCH 01/39] Add helper method --- src/editor/range.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/editor/range.ts b/src/editor/range.ts index 4336a151306..1d8443eb7b8 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -55,6 +55,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) => { From d5a145d66b7085d8a4acd4ea408b1d2aca0ef0e9 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 00:11:36 +0200 Subject: [PATCH 02/39] Add shortcut reference --- src/components/views/rooms/MessageComposerFormatBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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} /> ); } From ccc11128c32ff65499e8b2c9c4b2986242f70c69 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 00:12:17 +0200 Subject: [PATCH 03/39] Add shortcut logic --- .../views/rooms/BasicMessageComposer.tsx | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index f7621a67989..803463674ee 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -45,7 +45,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, { Predicate } from "../../../editor/position"; import { ICompletion } from "../../../autocomplete/Autocompleter"; import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -66,8 +66,8 @@ const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([ ["<", ">"], ]); -function ctrlShortcutLabel(key: string): string { - return (IS_MAC ? "⌘" : "Ctrl") + "+" + key; +function ctrlShortcutLabel(key: string, useShift?: boolean, useAlt?: boolean): string { + return (IS_MAC ? "⌘" : "Ctrl") + (useShift ? "+Shift" : "") + (useAlt ? "+Alt" : "") + "+" + key; } function cloneSelection(selection: Selection): Partial { @@ -522,10 +522,18 @@ export default class BasicMessageEditor extends React.Component this.onFormatAction(Formatting.Italics); handled = true; break; + case MessageComposerAction.FormatCode: + this.onFormatAction(Formatting.Code); + handled = true; + break; case MessageComposerAction.FormatQuote: this.onFormatAction(Formatting.Quote); handled = true; break; + case MessageComposerAction.FormatLink: + this.onFormatAction(Formatting.InsertLink); + handled = true; + break; case MessageComposerAction.EditRedo: if (this.historyManager.canRedo()) { const { parts, caret } = this.historyManager.redo(); @@ -681,16 +689,49 @@ export default class BasicMessageEditor extends React.Component } return caretPosition; } + + private getRangeOfWordAtCaret(): Range { + const { model } = this.props; + let caret = this.getCaret(); + let position = model.positionForOffset(caret.offset, caret.atNodeEnd); + const range = model.startRange(position); + + // Select left side of word + range.expandForwardsWhile((index, offset, part) => { + return part.text[offset] !== " " && part.text[offset] !== "+" && ( + part.type === Type.Plain || + part.type === Type.PillCandidate || + part.type === Type.Command + ); + }); + + // Reset caret position + caret = this.getCaret(); + position = model.positionForOffset(caret.offset, caret.atNodeEnd); + + // Select right side of word + range.expandBackwardsWhile((index, offset, part) => { + return part.text[offset] !== " " && part.text[offset] !== "+" && ( + part.type === Type.Plain || + part.type === Type.PillCandidate || + part.type === Type.Command + ); + }); + + return range; + } 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(); + let range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); - if (range.length === 0) { - return; + // If the user didn't select any text, we select the current word instead + if (range.length == 0) { + range = this.getRangeOfWordAtCaret(); } + // trim the range as we want it to exclude leading/trailing spaces + range.trim(); + this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; switch (action) { @@ -742,7 +783,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("K", false, true), }; const { completionIndex } = this.state; From cf1fd8b20d0004ee71798d2c8c2a3397f1511151 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 00:14:32 +0200 Subject: [PATCH 04/39] Add keybindings --- src/KeyBindingsDefaults.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 6169f431f46..02bd6cf0396 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -69,14 +69,28 @@ const messageComposerBindings = (): KeyBinding[] => { ctrlOrCmd: true, }, }, + { + action: MessageComposerAction.FormatCode, + keyCombo: { + key: Key.E, + ctrlOrCmd: true, + } + }, { action: MessageComposerAction.FormatQuote, keyCombo: { key: Key.GREATER_THAN, ctrlOrCmd: true, - shiftKey: true, }, }, + { + action: MessageComposerAction.FormatLink, + keyCombo: { + key: Key.K, + ctrlOrCmd: true, + altKey: true, + } + }, { action: MessageComposerAction.EditUndo, keyCombo: { From 8cb6355025fcbe428160e828905e2ee162b9e1c5 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 00:16:04 +0200 Subject: [PATCH 05/39] Add links and code format to MessageComposerAction --- src/KeyBindingsManager.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 3a893e2ec8a..6f55b37bf52 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -36,6 +36,10 @@ export enum MessageComposerAction { FormatBold = 'FormatBold', /** Set italics format the current selection */ FormatItalics = 'FormatItalics', + /** Insert link for current selection */ + FormatLink = 'FormatLink', + /** Set code format for current selection */ + FormatCode = 'FormatCode', /** Format the current selection as quote */ FormatQuote = 'FormatQuote', /** Undo the last editing */ From 4ece17eaaba67a64fc015be9e216a55cc8fc8827 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 12:17:17 +0200 Subject: [PATCH 06/39] Small fixes and refactoring --- src/KeyBindingsDefaults.ts | 5 +-- .../views/rooms/BasicMessageComposer.tsx | 31 ++++++++----------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 02bd6cf0396..ef888bcf3e7 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -74,13 +74,14 @@ const messageComposerBindings = (): KeyBinding[] => { keyCombo: { key: Key.E, ctrlOrCmd: true, - } + }, }, { action: MessageComposerAction.FormatQuote, keyCombo: { key: Key.GREATER_THAN, ctrlOrCmd: true, + shiftKey: true, }, }, { @@ -89,7 +90,7 @@ const messageComposerBindings = (): KeyBinding[] => { key: Key.K, ctrlOrCmd: true, altKey: true, - } + }, }, { action: MessageComposerAction.EditUndo, diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 803463674ee..090c00fe4e7 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -32,7 +32,7 @@ import { } from '../../../editor/operations'; import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom'; import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; -import { getAutoCompleteCreator, Type } from '../../../editor/parts'; +import { getAutoCompleteCreator, Part, Type } from '../../../editor/parts'; import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; import { renderModel } from '../../../editor/render'; import TypingStore from "../../../stores/TypingStore"; @@ -45,7 +45,6 @@ import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar import DocumentOffset from "../../../editor/offset"; import { IDiff } from "../../../editor/diff"; import AutocompleteWrapperModel from "../../../editor/autocomplete"; -import DocumentPosition, { Predicate } from "../../../editor/position"; import { ICompletion } from "../../../autocomplete/Autocompleter"; import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -585,11 +584,7 @@ export default class BasicMessageEditor extends React.Component const position = model.positionForOffset(caret.offset, caret.atNodeEnd); const range = model.startRange(position); range.expandBackwardsWhile((index, offset, part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" && ( - part.type === Type.Plain || - part.type === Type.PillCandidate || - part.type === Type.Command - ); + return this.isEndOfWord(offset, part); }); const { partCreator } = model; // await for auto-complete to be open @@ -689,7 +684,15 @@ export default class BasicMessageEditor extends React.Component } return caretPosition; } - + + private isEndOfWord(offset: number, part: Part): boolean { + return part.text[offset] !== " " && part.text[offset] !== "+" && ( + part.type === Type.Plain || + part.type === Type.PillCandidate || + part.type === Type.Command + ); + } + private getRangeOfWordAtCaret(): Range { const { model } = this.props; let caret = this.getCaret(); @@ -698,11 +701,7 @@ export default class BasicMessageEditor extends React.Component // Select left side of word range.expandForwardsWhile((index, offset, part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" && ( - part.type === Type.Plain || - part.type === Type.PillCandidate || - part.type === Type.Command - ); + return this.isEndOfWord(offset, part); }); // Reset caret position @@ -711,11 +710,7 @@ export default class BasicMessageEditor extends React.Component // Select right side of word range.expandBackwardsWhile((index, offset, part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" && ( - part.type === Type.Plain || - part.type === Type.PillCandidate || - part.type === Type.Command - ); + return this.isEndOfWord(offset, part); }); return range; From 8bbfdf0c91102ffc9bdbada3d941ff2c9d0fd352 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 12:21:46 +0200 Subject: [PATCH 07/39] Fix naming logic --- src/components/views/rooms/BasicMessageComposer.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 090c00fe4e7..98bd31fd0c9 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -584,7 +584,7 @@ export default class BasicMessageEditor extends React.Component const position = model.positionForOffset(caret.offset, caret.atNodeEnd); const range = model.startRange(position); range.expandBackwardsWhile((index, offset, part) => { - return this.isEndOfWord(offset, part); + return this.isNotEndOfWord(offset, part); }); const { partCreator } = model; // await for auto-complete to be open @@ -685,7 +685,7 @@ export default class BasicMessageEditor extends React.Component return caretPosition; } - private isEndOfWord(offset: number, part: Part): boolean { + private isNotEndOfWord(offset: number, part: Part): boolean { return part.text[offset] !== " " && part.text[offset] !== "+" && ( part.type === Type.Plain || part.type === Type.PillCandidate || @@ -701,7 +701,7 @@ export default class BasicMessageEditor extends React.Component // Select left side of word range.expandForwardsWhile((index, offset, part) => { - return this.isEndOfWord(offset, part); + return this.isNotEndOfWord(offset, part); }); // Reset caret position @@ -710,7 +710,7 @@ export default class BasicMessageEditor extends React.Component // Select right side of word range.expandBackwardsWhile((index, offset, part) => { - return this.isEndOfWord(offset, part); + return this.isNotEndOfWord(offset, part); }); return range; From efee3fce8da17612513c4fb1b065022694e0f35e Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 30 Oct 2021 12:39:54 +0200 Subject: [PATCH 08/39] Improve signature --- src/components/views/rooms/BasicMessageComposer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 98bd31fd0c9..c53a2300eee 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -65,8 +65,8 @@ const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([ ["<", ">"], ]); -function ctrlShortcutLabel(key: string, useShift?: boolean, useAlt?: boolean): string { - return (IS_MAC ? "⌘" : "Ctrl") + (useShift ? "+Shift" : "") + (useAlt ? "+Alt" : "") + "+" + key; +function ctrlShortcutLabel(key: string, needsShift = false, needsAlt = false): string { + return (IS_MAC ? "⌘" : "Ctrl") + (needsShift ? "+Shift") + (needsAlt ? "+Alt" : "") + "+" + key; } function cloneSelection(selection: Selection): Partial { From 5b1b0b61d5c52e1ecf4d1c0d6be28299effd50b7 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 1 Nov 2021 20:13:27 +0100 Subject: [PATCH 09/39] Add toggle function for code formatting --- .../views/rooms/BasicMessageComposer.tsx | 3 +- src/editor/operations.ts | 56 ++++++++++++++----- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index c53a2300eee..006cc892a86 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -48,6 +48,7 @@ import AutocompleteWrapperModel from "../../../editor/autocomplete"; import { ICompletion } from "../../../autocomplete/Autocompleter"; import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import DocumentPosition from '../../../editor/position'; import { logger } from "matrix-js-sdk/src/logger"; @@ -66,7 +67,7 @@ const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([ ]); function ctrlShortcutLabel(key: string, needsShift = false, needsAlt = false): string { - return (IS_MAC ? "⌘" : "Ctrl") + (needsShift ? "+Shift") + (needsAlt ? "+Alt" : "") + "+" + key; + return (IS_MAC ? "⌘" : "Ctrl") + (needsShift ? "+Shift" : "") + (needsAlt ? "+Alt" : "") + "+" + key; } function cloneSelection(selection: Selection): Partial { diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 85c0b783aa1..bcf3b9543b8 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -15,12 +15,23 @@ limitations under the License. */ import Range from "./range"; -import { Part, Type } from "./parts"; +import { Part, PartCreator, Type } from "./parts"; /** * Some common queries and transformations on the editor model */ + export function stripFormatting(parts: Part[], base: any, prefix: string, partCreator: PartCreator, index: any, suffix: string) { + const partWithoutPrefix = parts[base].serialize(); + partWithoutPrefix.text = partWithoutPrefix.text.substring(prefix.length); + parts[base] = partCreator.deserializePart(partWithoutPrefix); + + const partWithoutSuffix = parts[index - 1].serialize(); + const suffixPartText = partWithoutSuffix.text; + partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length); + parts[index - 1] = partCreator.deserializePart(partWithoutSuffix); +} + export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void { const { model } = range; model.transform(() => { @@ -84,8 +95,23 @@ 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) { + // Check whether the block formatting is on its own line + if (parts[range.start.index + 1]?.text == "\n" && parts[range.end.index - 1]?.text == "\n") { + // Remove part containing new line + parts[range.start.index + 1].remove(0, 1); + parts[range.end.index - 1].remove(0, 1); + } + // Need to add +1 to range.end.index since stripFormatting subtracts one again + stripFormatting(parts, range.start.index, "```", partCreator, range.end.index + 1, "```"); + } else if (needsBlockFormatting) { parts.unshift(partCreator.plain("```"), partCreator.newline()); if (!rangeStartsAtBeginningOfLine(range)) { parts.unshift(partCreator.newline()); @@ -96,10 +122,19 @@ export function formatRangeAsCode(range: Range): void { if (!rangeEndsAtEndOfLine(range)) { parts.push(partCreator.newline()); } - } else { - parts.unshift(partCreator.plain("`")); - parts.push(partCreator.plain("`")); + } else { + const hasInlineFormatting = (range.length > 0) + && range.text.startsWith("`") + && range.text.endsWith("`"); + if (hasInlineFormatting) { + toggleInlineFormat(range, "`"); + return; + } else { + parts.unshift(partCreator.plain("`")); + parts.push(partCreator.plain("`")); + } } + replaceRangeAndExpandSelection(range, parts); } @@ -163,14 +198,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix if (isFormatted) { // remove prefix and suffix - const partWithoutPrefix = parts[base].serialize(); - partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length); - parts[base] = partCreator.deserializePart(partWithoutPrefix); - - const partWithoutSuffix = parts[index - 1].serialize(); - const suffixPartText = partWithoutSuffix.text; - partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length); - parts[index - 1] = partCreator.deserializePart(partWithoutSuffix); + stripFormatting(parts, base, prefix, partCreator, index, suffix); } else { parts.splice(index, 0, partCreator.plain(suffix)); // splice in the later one first to not change offset parts.splice(base, 0, partCreator.plain(prefix)); From 617c686b91f8ed10d226d788d1d23edefd56f88d Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 1 Nov 2021 20:51:15 +0100 Subject: [PATCH 10/39] Clean up naming logic and some refactoring --- .../views/rooms/BasicMessageComposer.tsx | 22 +++++++++++++------ src/editor/operations.ts | 16 +++++++------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 006cc892a86..33c2c74537a 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -585,7 +585,7 @@ export default class BasicMessageEditor extends React.Component const position = model.positionForOffset(caret.offset, caret.atNodeEnd); const range = model.startRange(position); range.expandBackwardsWhile((index, offset, part) => { - return this.isNotEndOfWord(offset, part); + return this.isWord(offset, part); }); const { partCreator } = model; // await for auto-complete to be open @@ -686,7 +686,7 @@ export default class BasicMessageEditor extends React.Component return caretPosition; } - private isNotEndOfWord(offset: number, part: Part): boolean { + private isWord(offset: number, part: Part): boolean { return part.text[offset] !== " " && part.text[offset] !== "+" && ( part.type === Type.Plain || part.type === Type.PillCandidate || @@ -702,7 +702,7 @@ export default class BasicMessageEditor extends React.Component // Select left side of word range.expandForwardsWhile((index, offset, part) => { - return this.isNotEndOfWord(offset, part); + return this.isWord(offset, part); }); // Reset caret position @@ -711,7 +711,7 @@ export default class BasicMessageEditor extends React.Component // Select right side of word range.expandBackwardsWhile((index, offset, part) => { - return this.isNotEndOfWord(offset, part); + return this.isWord(offset, part); }); return range; @@ -723,11 +723,19 @@ export default class BasicMessageEditor extends React.Component // If the user didn't select any text, we select the current word instead if (range.length == 0) { range = this.getRangeOfWordAtCaret(); - } + } else { + let initalRange = range; - // trim the range as we want it to exclude leading/trailing spaces - range.trim(); + // trim the range as we want it to exclude leading/trailing spaces + range.trim(); + // Edgecase: When just selecting whitespace or new line. We don"t want + // to trim here, as the selection will jump and behave weirdly. + if (range.length == 0) { + range = initalRange; + } + } + this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; switch (action) { diff --git a/src/editor/operations.ts b/src/editor/operations.ts index bcf3b9543b8..b9cdcd31db8 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -21,15 +21,15 @@ import { Part, PartCreator, Type } from "./parts"; * Some common queries and transformations on the editor model */ - export function stripFormatting(parts: Part[], base: any, prefix: string, partCreator: PartCreator, index: any, suffix: string) { + export function stripFormattingSymbols(parts: Part[], base: any, prefix: string, partCreator: PartCreator, index: any, suffix: string) { const partWithoutPrefix = parts[base].serialize(); partWithoutPrefix.text = partWithoutPrefix.text.substring(prefix.length); parts[base] = partCreator.deserializePart(partWithoutPrefix); - const partWithoutSuffix = parts[index - 1].serialize(); + const partWithoutSuffix = parts[index].serialize(); const suffixPartText = partWithoutSuffix.text; partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length); - parts[index - 1] = partCreator.deserializePart(partWithoutSuffix); + parts[index] = partCreator.deserializePart(partWithoutSuffix); } export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void { @@ -109,8 +109,8 @@ export function formatRangeAsCode(range: Range): void { parts[range.start.index + 1].remove(0, 1); parts[range.end.index - 1].remove(0, 1); } - // Need to add +1 to range.end.index since stripFormatting subtracts one again - stripFormatting(parts, range.start.index, "```", partCreator, range.end.index + 1, "```"); + // remove prefix and suffix formatting string + stripFormattingSymbols(parts, range.start.index, "```", partCreator, range.end.index, "```"); } else if (needsBlockFormatting) { parts.unshift(partCreator.plain("```"), partCreator.newline()); if (!rangeStartsAtBeginningOfLine(range)) { @@ -134,7 +134,7 @@ export function formatRangeAsCode(range: Range): void { parts.push(partCreator.plain("`")); } } - + replaceRangeAndExpandSelection(range, parts); } @@ -197,8 +197,8 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix parts[index - 1].text.endsWith(suffix); if (isFormatted) { - // remove prefix and suffix - stripFormatting(parts, base, prefix, partCreator, index, suffix); + // remove prefix and suffix formatting string + stripFormattingSymbols(parts, base, prefix, partCreator, index - 1, suffix); } else { parts.splice(index, 0, partCreator.plain(suffix)); // splice in the later one first to not change offset parts.splice(base, 0, partCreator.plain(prefix)); From a7f28169b6bf5176bf3d7d61338ec9317bb13a4d Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 1 Nov 2021 21:51:57 +0100 Subject: [PATCH 11/39] Improve handling of whitespace --- .../views/rooms/BasicMessageComposer.tsx | 16 +++++++--------- src/editor/range.ts | 3 +++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 33c2c74537a..47667e1c93d 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -720,20 +720,18 @@ export default class BasicMessageEditor extends React.Component private onFormatAction = (action: Formatting): void => { let range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); + // Edgecase when just selecting whitespace or new line. We don't want + // to trim here, as the selection will jump and behave weirdly + if (range.text.trim().length == 0) { + return; + } + // If the user didn't select any text, we select the current word instead if (range.length == 0) { range = this.getRangeOfWordAtCaret(); } else { - let initalRange = range; - - // trim the range as we want it to exclude leading/trailing spaces + // Trim the range as we want it to exclude leading/trailing spaces range.trim(); - - // Edgecase: When just selecting whitespace or new line. We don"t want - // to trim here, as the selection will jump and behave weirdly. - if (range.length == 0) { - range = initalRange; - } } this.historyManager.ensureLastChangesPushed(this.props.model); diff --git a/src/editor/range.ts b/src/editor/range.ts index 1d8443eb7b8..400fee5fc0c 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -48,6 +48,9 @@ export default class Range { public trim(): void { this._start = this._start.forwardsWhile(this.model, whitespacePredicate); + if (this.length == 0) { + return; + } this._end = this._end.backwardsWhile(this.model, whitespacePredicate); } From e53f68d4384467522c8b9f4b94c594cf9a45664d Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 1 Nov 2021 21:56:37 +0100 Subject: [PATCH 12/39] Add more elaborate comment about whitespace handling --- src/components/views/rooms/BasicMessageComposer.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 47667e1c93d..4e1ac9b08ac 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -720,8 +720,10 @@ export default class BasicMessageEditor extends React.Component private onFormatAction = (action: Formatting): void => { let range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); - // Edgecase when just selecting whitespace or new line. We don't want - // to trim here, as the selection will jump and behave weirdly + // Edgecase when just selecting whitespace or new line. + // There should be no reason to format whitespace, so we just return. + // This also prevents weird, jumpy selection behavior that would occur + // in this case, that is caused by the later trim. if (range.text.trim().length == 0) { return; } @@ -731,7 +733,7 @@ export default class BasicMessageEditor extends React.Component range = this.getRangeOfWordAtCaret(); } else { // Trim the range as we want it to exclude leading/trailing spaces - range.trim(); + range.trim } this.historyManager.ensureLastChangesPushed(this.props.model); From f358be3e3c536be4549446ca7033419fdeebe06b Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 1 Nov 2021 22:07:25 +0100 Subject: [PATCH 13/39] Fix trim --- src/components/views/rooms/BasicMessageComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 4e1ac9b08ac..42d869c037b 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -733,7 +733,7 @@ export default class BasicMessageEditor extends React.Component range = this.getRangeOfWordAtCaret(); } else { // Trim the range as we want it to exclude leading/trailing spaces - range.trim + range.trim(); } this.historyManager.ensureLastChangesPushed(this.props.model); From e63ef6c0e6313b720abc2c1e07589555dbf234df Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 1 Nov 2021 23:24:12 +0100 Subject: [PATCH 14/39] Cleaner, more robust multiline toggle --- src/editor/operations.ts | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index b9cdcd31db8..c096dfa75e8 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -15,23 +15,12 @@ limitations under the License. */ import Range from "./range"; -import { Part, PartCreator, Type } from "./parts"; +import { Part, Type } from "./parts"; /** * Some common queries and transformations on the editor model */ - export function stripFormattingSymbols(parts: Part[], base: any, prefix: string, partCreator: PartCreator, index: any, suffix: string) { - const partWithoutPrefix = parts[base].serialize(); - partWithoutPrefix.text = partWithoutPrefix.text.substring(prefix.length); - parts[base] = partCreator.deserializePart(partWithoutPrefix); - - const partWithoutSuffix = parts[index].serialize(); - const suffixPartText = partWithoutSuffix.text; - partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length); - parts[index] = partCreator.deserializePart(partWithoutSuffix); -} - export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void { const { model } = range; model.transform(() => { @@ -103,14 +92,13 @@ export function formatRangeAsCode(range: Range): void { const needsBlockFormatting = parts.some(p => p.type === Type.Newline); if (hasBlockFormatting) { - // Check whether the block formatting is on its own line - if (parts[range.start.index + 1]?.text == "\n" && parts[range.end.index - 1]?.text == "\n") { - // Remove part containing new line - parts[range.start.index + 1].remove(0, 1); - parts[range.end.index - 1].remove(0, 1); + // 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(); } - // remove prefix and suffix formatting string - stripFormattingSymbols(parts, range.start.index, "```", partCreator, range.end.index, "```"); } else if (needsBlockFormatting) { parts.unshift(partCreator.plain("```"), partCreator.newline()); if (!rangeStartsAtBeginningOfLine(range)) { @@ -128,7 +116,7 @@ export function formatRangeAsCode(range: Range): void { && range.text.endsWith("`"); if (hasInlineFormatting) { toggleInlineFormat(range, "`"); - return; + return; // Toggle already does replace the range for us } else { parts.unshift(partCreator.plain("`")); parts.push(partCreator.plain("`")); @@ -198,7 +186,14 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix if (isFormatted) { // remove prefix and suffix formatting string - stripFormattingSymbols(parts, base, prefix, partCreator, index - 1, suffix); + const partWithoutPrefix = parts[base].serialize(); + partWithoutPrefix.text = partWithoutPrefix.text.substring(prefix.length); + parts[base] = partCreator.deserializePart(partWithoutPrefix); + + const partWithoutSuffix = parts[index - 1].serialize(); + const suffixPartText = partWithoutSuffix.text; + partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length); + parts[index - 1] = partCreator.deserializePart(partWithoutSuffix); } else { parts.splice(index, 0, partCreator.plain(suffix)); // splice in the later one first to not change offset parts.splice(base, 0, partCreator.plain(prefix)); From 19f9527c87d8351c8ebe988dcada83f7463d0898 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Wed, 3 Nov 2021 23:02:51 +0100 Subject: [PATCH 15/39] Add caret reset for no selection --- .../views/rooms/BasicMessageComposer.tsx | 16 +++---- src/editor/operations.ts | 46 +++++++++++++------ src/editor/range.ts | 16 +++++-- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 42d869c037b..a631c48596c 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -694,7 +694,7 @@ export default class BasicMessageEditor extends React.Component ); } - private getRangeOfWordAtCaret(): Range { + private getRangeOfWordAtCaretPosition(): Range { const { model } = this.props; let caret = this.getCaret(); let position = model.positionForOffset(caret.offset, caret.atNodeEnd); @@ -705,7 +705,7 @@ export default class BasicMessageEditor extends React.Component return this.isWord(offset, part); }); - // Reset caret position + // Reset to caret position caret = this.getCaret(); position = model.positionForOffset(caret.offset, caret.atNodeEnd); @@ -724,17 +724,17 @@ export default class BasicMessageEditor extends React.Component // There should be no reason to format whitespace, so we just return. // This also prevents weird, jumpy selection behavior that would occur // in this case, that is caused by the later trim. - if (range.text.trim().length == 0) { + if (range.length > 0 && range.text.trim().length === 0) { return; } // If the user didn't select any text, we select the current word instead - if (range.length == 0) { - range = this.getRangeOfWordAtCaret(); - } else { - // Trim the range as we want it to exclude leading/trailing spaces - range.trim(); + if (range.length === 0) { + range = this.getRangeOfWordAtCaretPosition(); } + + // Trim the range as we want it to exclude leading/trailing spaces + // range.trim(); this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; diff --git a/src/editor/operations.ts b/src/editor/operations.ts index c096dfa75e8..2d015b80e4d 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -43,6 +43,17 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset }); } + +export function replaceRangeAndResetCaret(range: Range, newParts: Part[], offset = 0): void { + const { model } = range; + model.transform(() => { + range.replace(newParts); + const initialPosition = range.getInitialPosition(); + const previousCaretOffset = initialPosition.asOffset(model).add(offset); + return previousCaretOffset.asPosition(model); + }); +} + export function rangeStartsAtBeginningOfLine(range: Range): boolean { const { model } = range; const startsWithPartial = range.start.offset !== 0; @@ -76,9 +87,13 @@ export function formatRangeAsQuote(range: Range): void { if (!rangeEndsAtEndOfLine(range)) { parts.push(partCreator.newline()); } - parts.push(partCreator.newline()); - replaceRangeAndExpandSelection(range, parts); + + if (range.wasInitializedEmpty()) { + replaceRangeAndMoveCaret(range, parts); + } else { + replaceRangeAndExpandSelection(range, parts); + } } export function formatRangeAsCode(range: Range): void { @@ -111,19 +126,11 @@ export function formatRangeAsCode(range: Range): void { parts.push(partCreator.newline()); } } else { - const hasInlineFormatting = (range.length > 0) - && range.text.startsWith("`") - && range.text.endsWith("`"); - if (hasInlineFormatting) { - toggleInlineFormat(range, "`"); - return; // Toggle already does replace the range for us - } else { - parts.unshift(partCreator.plain("`")); - parts.push(partCreator.plain("`")); - } + toggleInlineFormat(range, "`"); + return; } - replaceRangeAndExpandSelection(range, parts); + replaceRangeAndExpandSelection(range, parts); } export function formatRangeAsLink(range: Range) { @@ -201,5 +208,16 @@ 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()) { + // Check if we need to add a offset for a toggle or untoggle + if (range.text.startsWith(prefix) && range.text.endsWith(suffix)) { + replaceRangeAndResetCaret(range, parts, -prefix.length); + } else { + replaceRangeAndResetCaret(range, parts, prefix.length); + } + } else { + replaceRangeAndExpandSelection(range, parts); + } } diff --git a/src/editor/range.ts b/src/editor/range.ts index 400fee5fc0c..7dcb7d06f67 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -17,6 +17,7 @@ limitations under the License. import EditorModel from "./model"; import DocumentPosition, { Predicate } from "./position"; import { Part } from "./parts"; +import { logger } from "matrix-js-sdk/src/logger"; const whitespacePredicate: Predicate = (index, offset, part) => { return part.text[offset].trim() === ""; @@ -25,11 +26,15 @@ const whitespacePredicate: Predicate = (index, offset, part) => { export default class Range { private _start: DocumentPosition; private _end: DocumentPosition; + private _initialStart: DocumentPosition; + private _initalizedEmpty: 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._initialStart = this._start; + this._initalizedEmpty = this._start == this._end; } public moveStartForwards(delta: number): void { @@ -39,6 +44,14 @@ export default class Range { }); } + public wasInitializedEmpty(): boolean { + return this._initalizedEmpty; + } + + public getInitialPosition(): DocumentPosition { + return this._initialStart; + } + public moveEndBackwards(delta: number): void { this._end = this._end.backwardsWhile(this.model, () => { delta -= 1; @@ -48,9 +61,6 @@ export default class Range { public trim(): void { this._start = this._start.forwardsWhile(this.model, whitespacePredicate); - if (this.length == 0) { - return; - } this._end = this._end.backwardsWhile(this.model, whitespacePredicate); } From 35b1edcd91b2b89b63ea3210ce2a07a546230783 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Fri, 5 Nov 2021 00:26:05 +0100 Subject: [PATCH 16/39] Fix range condition and clean up --- .../views/rooms/BasicMessageComposer.tsx | 42 +++++++++---------- src/editor/operations.ts | 11 +++-- src/editor/range.ts | 1 - 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index a631c48596c..f2dd5deb059 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -585,7 +585,11 @@ export default class BasicMessageEditor extends React.Component const position = model.positionForOffset(caret.offset, caret.atNodeEnd); const range = model.startRange(position); range.expandBackwardsWhile((index, offset, part) => { - return this.isWord(offset, part); + return part.text[offset] !== " " && part.text[offset] !== "+" && ( + part.type === Type.Plain || + part.type === Type.PillCandidate || + part.type === Type.Command + ); }); const { partCreator } = model; // await for auto-complete to be open @@ -686,32 +690,25 @@ export default class BasicMessageEditor extends React.Component return caretPosition; } - private isWord(offset: number, part: Part): boolean { - return part.text[offset] !== " " && part.text[offset] !== "+" && ( - part.type === Type.Plain || - part.type === Type.PillCandidate || - part.type === Type.Command - ); + private isPlainWord(offset: number, part: Part): boolean { + return part.text[offset] !== " " && part.text[offset] !== "+" && part.text[offset] !== "+" + && part.type !== Type.Newline && part.type === Type.Plain; } private getRangeOfWordAtCaretPosition(): Range { const { model } = this.props; - let caret = this.getCaret(); - let position = model.positionForOffset(caret.offset, caret.atNodeEnd); + const caret = this.getCaret(); + const position = model.positionForOffset(caret.offset, caret.atNodeEnd); const range = model.startRange(position); - // Select left side of word - range.expandForwardsWhile((index, offset, part) => { - return this.isWord(offset, part); + // Select right side of word + range.expandForwardsWhile((_index, offset, part) => { + return this.isPlainWord(offset, part); }); - // Reset to caret position - caret = this.getCaret(); - position = model.positionForOffset(caret.offset, caret.atNodeEnd); - - // Select right side of word - range.expandBackwardsWhile((index, offset, part) => { - return this.isWord(offset, part); + // Select left side of word + range.expandBackwardsWhile((_index, offset, part) => { + return this.isPlainWord(offset, part); }); return range; @@ -721,7 +718,7 @@ export default class BasicMessageEditor extends React.Component let range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); // Edgecase when just selecting whitespace or new line. - // There should be no reason to format whitespace, so we just return. + // There should be no reason to format whitespace, so we can just return. // This also prevents weird, jumpy selection behavior that would occur // in this case, that is caused by the later trim. if (range.length > 0 && range.text.trim().length === 0) { @@ -732,10 +729,9 @@ export default class BasicMessageEditor extends React.Component if (range.length === 0) { range = this.getRangeOfWordAtCaretPosition(); } - // Trim the range as we want it to exclude leading/trailing spaces - // range.trim(); - + range.trim(); + this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; switch (action) { diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 2d015b80e4d..0c541e786e8 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -43,7 +43,6 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset }); } - export function replaceRangeAndResetCaret(range: Range, newParts: Part[], offset = 0): void { const { model } = range; model.transform(() => { @@ -125,12 +124,12 @@ export function formatRangeAsCode(range: Range): void { if (!rangeEndsAtEndOfLine(range)) { parts.push(partCreator.newline()); } - } else { + } else { toggleInlineFormat(range, "`"); return; } - replaceRangeAndExpandSelection(range, parts); + replaceRangeAndExpandSelection(range, parts); } export function formatRangeAsLink(range: Range) { @@ -196,7 +195,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix const partWithoutPrefix = parts[base].serialize(); partWithoutPrefix.text = partWithoutPrefix.text.substring(prefix.length); parts[base] = partCreator.deserializePart(partWithoutPrefix); - + const partWithoutSuffix = parts[index - 1].serialize(); const suffixPartText = partWithoutSuffix.text; partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length); @@ -213,9 +212,9 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix if (range.wasInitializedEmpty()) { // Check if we need to add a offset for a toggle or untoggle if (range.text.startsWith(prefix) && range.text.endsWith(suffix)) { - replaceRangeAndResetCaret(range, parts, -prefix.length); + replaceRangeAndResetCaret(range, parts, -prefix.length); } else { - replaceRangeAndResetCaret(range, parts, prefix.length); + replaceRangeAndResetCaret(range, parts, prefix.length); } } else { replaceRangeAndExpandSelection(range, parts); diff --git a/src/editor/range.ts b/src/editor/range.ts index 7dcb7d06f67..e881764410f 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -17,7 +17,6 @@ limitations under the License. import EditorModel from "./model"; import DocumentPosition, { Predicate } from "./position"; import { Part } from "./parts"; -import { logger } from "matrix-js-sdk/src/logger"; const whitespacePredicate: Predicate = (index, offset, part) => { return part.text[offset].trim() === ""; From 19a519d9b224d5091af59ad529a9c24c1c35730e Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Fri, 5 Nov 2021 15:04:12 +0100 Subject: [PATCH 17/39] Refactoring --- .../views/rooms/BasicMessageComposer.tsx | 17 +++++++++++------ src/editor/operations.ts | 11 +++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index f2dd5deb059..543c75b518a 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -691,7 +691,7 @@ export default class BasicMessageEditor extends React.Component } private isPlainWord(offset: number, part: Part): boolean { - return part.text[offset] !== " " && part.text[offset] !== "+" && part.text[offset] !== "+" + return part.text[offset] !== " " && part.text[offset] !== "+" && part.type !== Type.Newline && part.type === Type.Plain; } @@ -702,15 +702,17 @@ export default class BasicMessageEditor extends React.Component const range = model.startRange(position); // Select right side of word - range.expandForwardsWhile((_index, offset, part) => { + range.expandForwardsWhile((index, offset, part) => { return this.isPlainWord(offset, part); }); // Select left side of word - range.expandBackwardsWhile((_index, offset, part) => { + range.expandBackwardsWhile((index, offset, part) => { return this.isPlainWord(offset, part); }); - + + // Cut off new lines + range.trim(); return range; } @@ -727,10 +729,13 @@ export default class BasicMessageEditor extends React.Component // If the user didn't select any text, we select the current word instead if (range.length === 0) { + // Already correctly trimmed range = this.getRangeOfWordAtCaretPosition(); + } else { + // Trim the range as we want it to exclude leading/trailing spaces + range.trim(); } - // Trim the range as we want it to exclude leading/trailing spaces - range.trim(); + this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 0c541e786e8..cc93ab21b86 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -43,7 +43,9 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset }); } -export function replaceRangeAndResetCaret(range: Range, newParts: Part[], offset = 0): void { +export function replaceRangeAndResetCaret(range: Range, newParts: Part[], offset = 0, shrinked = false): void { + shrinked ? offset = -offset : offset; + const { model } = range; model.transform(() => { range.replace(newParts); @@ -211,11 +213,8 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix // the caret position instead of making a new selection. if (range.wasInitializedEmpty()) { // Check if we need to add a offset for a toggle or untoggle - if (range.text.startsWith(prefix) && range.text.endsWith(suffix)) { - replaceRangeAndResetCaret(range, parts, -prefix.length); - } else { - replaceRangeAndResetCaret(range, parts, prefix.length); - } + const hasFormatting = range.text.startsWith(prefix) && range.text.endsWith(suffix); + replaceRangeAndResetCaret(range, parts, prefix.length, hasFormatting); } else { replaceRangeAndExpandSelection(range, parts); } From eb5fadebf2e054def4d9a2d23531c4a949d4397a Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Fri, 5 Nov 2021 15:47:51 +0100 Subject: [PATCH 18/39] Linter: Remove empty line --- src/components/views/rooms/BasicMessageComposer.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 543c75b518a..f262b929460 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -710,7 +710,7 @@ export default class BasicMessageEditor extends React.Component range.expandBackwardsWhile((index, offset, part) => { return this.isPlainWord(offset, part); }); - + // Cut off new lines range.trim(); return range; @@ -731,12 +731,11 @@ export default class BasicMessageEditor extends React.Component if (range.length === 0) { // Already correctly trimmed range = this.getRangeOfWordAtCaretPosition(); - } else { + } else { // Trim the range as we want it to exclude leading/trailing spaces range.trim(); } - this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; switch (action) { From 5f9817a15a79e1e3da0af3c90801ec95e60a71a1 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sun, 7 Nov 2021 22:34:29 +0100 Subject: [PATCH 19/39] Extract format range to operations and add unit tests --- .../views/rooms/BasicMessageComposer.tsx | 79 +------ src/editor/operations.ts | 71 ++++++- src/editor/range.ts | 14 +- test/editor/operations-test.js | 196 +++++++++++++++++- 4 files changed, 274 insertions(+), 86 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index f262b929460..f9f541db0fd 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -23,16 +23,10 @@ import EMOTICON_REGEX from 'emojibase-regex/emoticon'; 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, Part, Type } from '../../../editor/parts'; +import { getAutoCompleteCreator, Type } from '../../../editor/parts'; import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; import { renderModel } from '../../../editor/render'; import TypingStore from "../../../stores/TypingStore"; @@ -690,74 +684,13 @@ export default class BasicMessageEditor extends React.Component return caretPosition; } - private isPlainWord(offset: number, part: Part): boolean { - return part.text[offset] !== " " && part.text[offset] !== "+" - && part.type !== Type.Newline && part.type === Type.Plain; - } - - private getRangeOfWordAtCaretPosition(): Range { - const { model } = this.props; - const caret = this.getCaret(); - const position = model.positionForOffset(caret.offset, caret.atNodeEnd); - const range = model.startRange(position); - - // Select right side of word - range.expandForwardsWhile((index, offset, part) => { - return this.isPlainWord(offset, part); - }); - - // Select left side of word - range.expandBackwardsWhile((index, offset, part) => { - return this.isPlainWord(offset, part); - }); - - // Cut off new lines - range.trim(); - return range; - } - - private onFormatAction = (action: Formatting): void => { - let range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); - - // Edgecase when just selecting whitespace or new line. - // There should be no reason to format whitespace, so we can just return. - // This also prevents weird, jumpy selection behavior that would occur - // in this case, that is caused by the later trim. - if (range.length > 0 && range.text.trim().length === 0) { - return; - } - - // If the user didn't select any text, we select the current word instead - if (range.length === 0) { - // Already correctly trimmed - range = this.getRangeOfWordAtCaretPosition(); - } else { - // Trim the range as we want it to exclude leading/trailing spaces - range.trim(); - } + 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() { diff --git a/src/editor/operations.ts b/src/editor/operations.ts index cc93ab21b86..a2a843eb016 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -16,11 +16,47 @@ 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 */ +export function formatRange(range: Range, action: Formatting) { + if (range.wasInitializedEmpty()) { + range = getRangeOfWordAtCaretPosition(range); + } + + // Edgecase when just selecting whitespace or new line. + // There should be no reason to format whitespace, so we can just return. + if (range.text.trim().length == 0) { + return; + } + + range.trim(); + + 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(() => { @@ -43,14 +79,15 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset }); } -export function replaceRangeAndResetCaret(range: Range, newParts: Part[], offset = 0, shrinked = false): void { - shrinked ? offset = -offset : offset; - +export function replaceRangeAndResetCaret(range: Range, newParts: Part[], offset = 0, toggled = false): void { const { model } = range; + + // Flip offset sign depening on whether we toggle or untoggle formatting + toggled ? offset = -offset + 1 : offset; + model.transform(() => { range.replace(newParts); - const initialPosition = range.getInitialPosition(); - const previousCaretOffset = initialPosition.asOffset(model).add(offset); + const previousCaretOffset = range.getInitialPosition().asOffset(model).add(offset); return previousCaretOffset.asPosition(model); }); } @@ -134,6 +171,30 @@ export function formatRangeAsCode(range: Range): void { replaceRangeAndExpandSelection(range, parts); } +const isPlainWord = (offset: number, part: Part) => { + return part.text[offset] !== " " && part.text[offset] !== "+" + && part.type !== Type.Newline && part.type === Type.Plain; +}; + +export function getRangeOfWordAtCaretPosition(range: Range): Range { + // Select right side of word + range.expandForwardsWhile((index, offset, part) => { + return isPlainWord(offset, part); + }); + + // Select left side of word + range.expandBackwardsWhile((index, offset, part) => { + return isPlainWord(offset, part); + }); + + // Cut off new lines + // This is needed since expandBackwardsWhile lands on the index of the previous new line if + // at the beginning of a line that is not the first line + range.trim(); + + return range; +} + export function formatRangeAsLink(range: Range) { const { model, parts } = range; const { partCreator } = model; diff --git a/src/editor/range.ts b/src/editor/range.ts index e881764410f..93c64e3d28d 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -17,8 +17,12 @@ limitations under the License. import EditorModel from "./model"; import DocumentPosition, { Predicate } from "./position"; import { Part } from "./parts"; +import { logger } from "matrix-js-sdk/src/logger"; const whitespacePredicate: Predicate = (index, offset, part) => { + if (part.text[offset] == null) { + logger.debug("Meme"); + } return part.text[offset].trim() === ""; }; @@ -26,14 +30,14 @@ export default class Range { private _start: DocumentPosition; private _end: DocumentPosition; private _initialStart: DocumentPosition; - private _initalizedEmpty: boolean; + 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._initialStart = this._start; - this._initalizedEmpty = this._start == this._end; + this._initializedEmpty = this._start.index === this._end.index && this._start.offset == this._end.offset; } public moveStartForwards(delta: number): void { @@ -44,7 +48,11 @@ export default class Range { } public wasInitializedEmpty(): boolean { - return this._initalizedEmpty; + return this._initializedEmpty; + } + + public setWasEmpty(value: boolean) { + this._initializedEmpty = value; } public getInitialPosition(): DocumentPosition { diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index 17a4c8ba118..d8048bc9e67 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, + getRangeOfWordAtCaretPosition, + 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,186 @@ describe('editor/operations: formatting operations', () => { { "text": "new paragraph", "type": "plain" }, ]); }); + + it('format word at caret position at beginning of line', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.newline(), + pc.plain("hello!"), + ], pc, renderer); + + let range = model.startRange(model.positionForOffset(0, false)); + + // Initial position should equal start and end since we did not select anything + expect(range.getInitialPosition()).toEqual(range.start); + expect(range.getInitialPosition()).toEqual(range.end); + + getRangeOfWordAtCaretPosition(range); + + expect(range.wasInitializedEmpty()).toEqual(true); + + // Preceding new line should never be selected in this case + expect(range.length).toBe(1); + expect(range.parts.map(p => p.text).join("")).toBe(""); + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "hello!", "type": "plain" }, + ]); + + getRangeOfWordAtCaretPosition(range); + formatRange(range, Formatting.Bold); + expect(range.start).toEqual(2); + expect(range.end).toEqual(2); + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "**hello!**", "type": "plain" }, + ]); + + // Untoggle + getRangeOfWordAtCaretPosition(range); + formatRange(range, Formatting.Bold); + expect(range.start).toEqual(0); + expect(range.end).toEqual(0); + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "hello!", "type": "plain" }, + ]); + + range = model.startRange(model.positionForOffset(1, false)); + getRangeOfWordAtCaretPosition(range); + + formatRange(range, Formatting.Code); + expect(range.start).toEqual(2); + expect(range.end).toEqual(2); + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "`hello!`", "type": "plain" }, + ]); + + getRangeOfWordAtCaretPosition(range); + + // Untoggle + formatRange(range, Formatting.Code); + expect(range.start).toEqual(1); + expect(range.end).toEqual(1); + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "hello!", "type": "plain" }, + ]); + + range = model.startRange(model.getPositionAtEnd()); + + formatRange(range, Formatting.InsertLink); + getRangeOfWordAtCaretPosition(range); + expect(range.start).toEqual(8); + expect(range.end).toEqual(8); + + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "[hello!]()", "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.newline(), + pc.plain("konnichiwa!"), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(8, false), model.getPositionAtEnd()); // select-all + + expect(range.parts.map(p => p.text).join("")).toBe("konnichiwa!"); + + expect(model.serializeParts()).toEqual([ + { "text": "hello!", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": "konnichiwa!", "type": "plain" }, + ]); + + formatRange(range, Formatting.InsertLink); + + expect(model.serializeParts()).toEqual([ + { "text": "hello!", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": "[konnichiwa!]()", "type": "plain" }, + ]); + }); + + it('format multi line code', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello!"), + pc.newline(), + pc.newline(), + pc.plain("konnichiwa!"), + ], pc, renderer); + + const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all + + expect(range.parts.map(p => p.text).join("")).toBe("hello!\n\nkonnichiwa!"); + + expect(model.serializeParts()).toEqual([ + { "text": "hello!", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": "konnichiwa!", "type": "plain" }, + ]); + + formatRange(range, Formatting.Code); + + expect(model.serializeParts()).toEqual([ + { "text": "```", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "hello!", "type": "plain" }, + SERIALIZED_NEWLINE, + SERIALIZED_NEWLINE, + { "text": "konnichiwa!", "type": "plain" }, + SERIALIZED_NEWLINE, + { "text": "```", "type": "plain" }, + ]); + }); + + it('format 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, false), 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" }, + ]); + }); }); }); From e07d1ba9c50d2d8dec4347f71794dd7d5a30f7e6 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 8 Nov 2021 17:56:28 +0100 Subject: [PATCH 20/39] Add pure whitespace handling to range --- src/editor/range.ts | 20 ++++++++++++-------- test/editor/range-test.js | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/editor/range.ts b/src/editor/range.ts index 93c64e3d28d..fc42fd186ed 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -17,26 +17,22 @@ limitations under the License. import EditorModel from "./model"; import DocumentPosition, { Predicate } from "./position"; import { Part } from "./parts"; -import { logger } from "matrix-js-sdk/src/logger"; const whitespacePredicate: Predicate = (index, offset, part) => { - if (part.text[offset] == null) { - logger.debug("Meme"); - } return part.text[offset].trim() === ""; }; export default class Range { private _start: DocumentPosition; private _end: DocumentPosition; - private _initialStart: 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._initialStart = this._start; + this._lastStart = this._start; this._initializedEmpty = this._start.index === this._end.index && this._start.offset == this._end.offset; } @@ -55,8 +51,12 @@ export default class Range { this._initializedEmpty = value; } - public getInitialPosition(): DocumentPosition { - return this._initialStart; + public getLastStartingPosition(): DocumentPosition { + return this._lastStart; + } + + public setLastStartingPosition(position: DocumentPosition): void { + this._lastStart = position; } public moveEndBackwards(delta: number): void { @@ -67,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); } diff --git a/test/editor/range-test.js b/test/editor/range-test.js index 87c5b06e44f..d2046b5f4a4 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -104,4 +104,20 @@ 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 model = new EditorModel([ + pc.plain(" \n \n\n"), + ], pc, renderer); + const range = model.startRange( + model.positionForOffset(0, false), + model.getPositionAtEnd(), + ); + + expect(range.text).toBe(" \n \n\n"); + range.trim(); + expect(range.text).toBe(""); + }); }); From 684e0f59c2dd861f610f9c4d8e1fa98d30a14271 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 8 Nov 2021 18:01:12 +0100 Subject: [PATCH 21/39] Handle deletion of caret position --- src/editor/operations.ts | 40 ++++++++++++------- test/editor/operations-test.js | 71 ++++++++++++++++------------------ 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index a2a843eb016..78d54177f18 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -23,18 +23,20 @@ import { Formatting } from "../components/views/rooms/MessageComposerFormatBar"; */ export function formatRange(range: Range, action: Formatting) { + // If the selection was empty we select the current word instead if (range.wasInitializedEmpty()) { range = getRangeOfWordAtCaretPosition(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.text.trim().length == 0) { + if (range.length == 0) { return; } - range.trim(); - switch (action) { case Formatting.Bold: toggleInlineFormat(range, "**"); @@ -79,15 +81,27 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset }); } -export function replaceRangeAndResetCaret(range: Range, newParts: Part[], offset = 0, toggled = false): void { +export function toggleFormattingForRangeAndResetCaret( + range: Range, newParts: Part[], rangeHasFormatting: boolean, + prefixLength: number, suffixLength = prefixLength): void { const { model } = range; - - // Flip offset sign depening on whether we toggle or untoggle formatting - toggled ? offset = -offset + 1 : offset; - model.transform(() => { - range.replace(newParts); - const previousCaretOffset = range.getInitialPosition().asOffset(model).add(offset); + // Shift the initialPosition + if (rangeHasFormatting) { + const relativeOffset = range.getLastStartingPosition().offset - range.start.offset; // Always positive + if (range.length - relativeOffset < suffixLength) { + const offset = (range.length - relativeOffset - suffixLength); + const start = range.getLastStartingPosition().asOffset(model).add(offset); + range.setLastStartingPosition(start.asPosition(model)); + } else if (relativeOffset < prefixLength) { + const offset = prefixLength - relativeOffset; + const start = range.getLastStartingPosition().asOffset(model).add(offset); + range.setLastStartingPosition(start.asPosition(model)); + } + } + const offset = range.replace(newParts) / 2; + const atEnd = range.getLastStartingPosition().asOffset(model).atNodeEnd; + const previousCaretOffset = range.getLastStartingPosition().asOffset(model).add(offset, atEnd); return previousCaretOffset.asPosition(model); }); } @@ -187,9 +201,7 @@ export function getRangeOfWordAtCaretPosition(range: Range): Range { return isPlainWord(offset, part); }); - // Cut off new lines - // This is needed since expandBackwardsWhile lands on the index of the previous new line if - // at the beginning of a line that is not the first line + // Trim possibly selected new lines range.trim(); return range; @@ -275,7 +287,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix if (range.wasInitializedEmpty()) { // Check if we need to add a offset for a toggle or untoggle const hasFormatting = range.text.startsWith(prefix) && range.text.endsWith(suffix); - replaceRangeAndResetCaret(range, parts, prefix.length, hasFormatting); + toggleFormattingForRangeAndResetCaret(range, parts, hasFormatting, prefix.length); } else { replaceRangeAndExpandSelection(range, parts); } diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index d8048bc9e67..b6ed2feb0d6 100644 --- a/test/editor/operations-test.js +++ b/test/editor/operations-test.js @@ -193,7 +193,7 @@ describe('editor/operations: formatting operations', () => { ]); }); - it('format word at caret position at beginning of line', () => { + it('format word at caret position at beginning of new line without selection', () => { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([ @@ -201,39 +201,25 @@ describe('editor/operations: formatting operations', () => { pc.plain("hello!"), ], pc, renderer); - let range = model.startRange(model.positionForOffset(0, false)); + let range = model.startRange(model.positionForOffset(2, false)); // Initial position should equal start and end since we did not select anything - expect(range.getInitialPosition()).toEqual(range.start); - expect(range.getInitialPosition()).toEqual(range.end); + expect(range.getLastStartingPosition()).toEqual(range.start); + expect(range.getLastStartingPosition()).toEqual(range.end); - getRangeOfWordAtCaretPosition(range); + range = getRangeOfWordAtCaretPosition(range); - expect(range.wasInitializedEmpty()).toEqual(true); + // Check whether selection has the correct length + expect(range.length).toBe("hello!".length); - // Preceding new line should never be selected in this case - expect(range.length).toBe(1); - expect(range.parts.map(p => p.text).join("")).toBe(""); - expect(model.serializeParts()).toEqual([ - SERIALIZED_NEWLINE, - { "text": "hello!", "type": "plain" }, - ]); - - getRangeOfWordAtCaretPosition(range); formatRange(range, Formatting.Bold); - expect(range.start).toEqual(2); - expect(range.end).toEqual(2); expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, { "text": "**hello!**", "type": "plain" }, ]); - // Untoggle - getRangeOfWordAtCaretPosition(range); - formatRange(range, Formatting.Bold); - expect(range.start).toEqual(0); - expect(range.end).toEqual(0); + formatRange(range, Formatting.Bold); // Untoggle expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, @@ -244,36 +230,47 @@ describe('editor/operations: formatting operations', () => { getRangeOfWordAtCaretPosition(range); formatRange(range, Formatting.Code); - expect(range.start).toEqual(2); - expect(range.end).toEqual(2); expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, { "text": "`hello!`", "type": "plain" }, ]); - getRangeOfWordAtCaretPosition(range); + formatRange(range, Formatting.Code); // Untoggle + expect(model.serializeParts()).toEqual([ + SERIALIZED_NEWLINE, + { "text": "hello!", "type": "plain" }, + ]); + }); - // Untoggle - formatRange(range, Formatting.Code); - expect(range.start).toEqual(1); - expect(range.end).toEqual(1); + 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!**"), + pc.newline(), + pc.plain("world"), + ], pc, renderer); expect(model.serializeParts()).toEqual([ + { "text": "**hello!**", "type": "plain" }, SERIALIZED_NEWLINE, - { "text": "hello!", "type": "plain" }, + { "text": "world", "type": "plain" }, ]); - range = model.startRange(model.getPositionAtEnd()); + const endOfFirstLine = 10; + const range = model.startRange(model.positionForOffset(endOfFirstLine, true)); + expect(range.getLastStartingPosition()).toEqual(range.start); + expect(range.getLastStartingPosition()).toEqual(range.end); - formatRange(range, Formatting.InsertLink); - getRangeOfWordAtCaretPosition(range); - expect(range.start).toEqual(8); - expect(range.end).toEqual(8); + formatRange(range, Formatting.Bold); // Toggle + formatRange(range, Formatting.Bold); // Untoggle + // We expected formatting to still happen in the first line as the caret should not jump down expect(model.serializeParts()).toEqual([ + { "text": "**hello!**", "type": "plain" }, SERIALIZED_NEWLINE, - { "text": "[hello!]()", "type": "plain" }, + { "text": "world", "type": "plain" }, ]); }); @@ -299,7 +296,6 @@ describe('editor/operations: formatting operations', () => { ]); formatRange(range, Formatting.InsertLink); - expect(model.serializeParts()).toEqual([ { "text": "hello!", "type": "plain" }, SERIALIZED_NEWLINE, @@ -354,7 +350,6 @@ describe('editor/operations: formatting operations', () => { ], pc, renderer); const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all - expect(range.parts.map(p => p.text).join("")).toBe(" \n\n "); expect(model.serializeParts()).toEqual([ From bf848785caa4167580c0712500cf47ef0a088a59 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 8 Nov 2021 22:34:56 +0100 Subject: [PATCH 22/39] Add comments to important methods, improve names and clean up signatures --- src/editor/operations.ts | 90 +++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 78d54177f18..caa7ac6cd61 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -22,7 +22,12 @@ import { Formatting } from "../components/views/rooms/MessageComposerFormatBar"; * Some common queries and transformations on the editor model */ -export function formatRange(range: Range, action: Formatting) { +/** + * 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()) { range = getRangeOfWordAtCaretPosition(range); @@ -81,22 +86,33 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset }); } -export function toggleFormattingForRangeAndResetCaret( - range: Range, newParts: Part[], rangeHasFormatting: boolean, - prefixLength: number, suffixLength = prefixLength): void { +/** + * Replaces a range with formatting or removes existing formatting. + * Then positions the cursor with respect to the prefix or suffix length. + * @param {Range} range the previous value + * @param {Part[]} newParts the new value + * @param {boolean} rangeHasFormatting the new value + * @param {number} formatStringLength the length of the format string, assumed to be 0 when not formatted + */ +export function replaceRangeAndAutoAdjustCaret( + range: Range, + newParts: Part[], + rangeHasFormatting = false, + formatStringLength = 0, +): void { const { model } = range; model.transform(() => { // Shift the initialPosition if (rangeHasFormatting) { const relativeOffset = range.getLastStartingPosition().offset - range.start.offset; // Always positive - if (range.length - relativeOffset < suffixLength) { - const offset = (range.length - relativeOffset - suffixLength); - const start = range.getLastStartingPosition().asOffset(model).add(offset); - range.setLastStartingPosition(start.asPosition(model)); - } else if (relativeOffset < prefixLength) { - const offset = prefixLength - relativeOffset; - const start = range.getLastStartingPosition().asOffset(model).add(offset); - range.setLastStartingPosition(start.asPosition(model)); + if (range.length - relativeOffset < formatStringLength) { + const correctionOffset = (range.length - relativeOffset - formatStringLength); + const newStart = range.getLastStartingPosition().asOffset(model).add(correctionOffset); + range.setLastStartingPosition(newStart.asPosition(model)); + } else if (relativeOffset < formatStringLength) { + const correctionOffset = formatStringLength - relativeOffset; + const newStart = range.getLastStartingPosition().asOffset(model).add(correctionOffset); + range.setLastStartingPosition(newStart.asPosition(model)); } } const offset = range.replace(newParts) / 2; @@ -106,6 +122,28 @@ export function toggleFormattingForRangeAndResetCaret( }); } +const isPlainWord = (offset: number, part: Part) => { + return part.text[offset] !== " " && part.text[offset] !== "+" + && part.type !== Type.Newline && part.type === Type.Plain; +}; + +export function getRangeOfWordAtCaretPosition(range: Range): Range { + // Select right side of word + range.expandForwardsWhile((_index, offset, part) => { + return isPlainWord(offset, part); + }); + + // Select left side of word + range.expandBackwardsWhile((_index, offset, part) => { + return isPlainWord(offset, part); + }); + + // Trim possibly selected new lines + range.trim(); + + return range; +} + export function rangeStartsAtBeginningOfLine(range: Range): boolean { const { model } = range; const startsWithPartial = range.start.offset !== 0; @@ -142,7 +180,7 @@ export function formatRangeAsQuote(range: Range): void { parts.push(partCreator.newline()); if (range.wasInitializedEmpty()) { - replaceRangeAndMoveCaret(range, parts); + replaceRangeAndAutoAdjustCaret(range, parts, false); } else { replaceRangeAndExpandSelection(range, parts); } @@ -185,28 +223,6 @@ export function formatRangeAsCode(range: Range): void { replaceRangeAndExpandSelection(range, parts); } -const isPlainWord = (offset: number, part: Part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" - && part.type !== Type.Newline && part.type === Type.Plain; -}; - -export function getRangeOfWordAtCaretPosition(range: Range): Range { - // Select right side of word - range.expandForwardsWhile((index, offset, part) => { - return isPlainWord(offset, part); - }); - - // Select left side of word - range.expandBackwardsWhile((index, offset, part) => { - return isPlainWord(offset, part); - }); - - // Trim possibly selected new lines - range.trim(); - - return range; -} - export function formatRangeAsLink(range: Range) { const { model, parts } = range; const { partCreator } = model; @@ -284,10 +300,10 @@ 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()) { + 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); - toggleFormattingForRangeAndResetCaret(range, parts, hasFormatting, prefix.length); + replaceRangeAndAutoAdjustCaret(range, parts, hasFormatting, prefix.length); } else { replaceRangeAndExpandSelection(range, parts); } From d880a3eb6bc4d0c965cf42ad11f87d566421ec43 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Tue, 9 Nov 2021 00:52:22 +0100 Subject: [PATCH 23/39] Improve tests to enable better checking of selection position --- src/editor/operations.ts | 13 +++---- test/editor/operations-test.js | 67 +++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index caa7ac6cd61..a343bb17d6b 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -30,7 +30,7 @@ import { Formatting } from "../components/views/rooms/MessageComposerFormatBar"; export function formatRange(range: Range, action: Formatting): void { // If the selection was empty we select the current word instead if (range.wasInitializedEmpty()) { - range = getRangeOfWordAtCaretPosition(range); + selectRangeOfWordAtCaret(range); } else { // Remove whitespace or new lines in our selection range.trim(); @@ -117,8 +117,9 @@ export function replaceRangeAndAutoAdjustCaret( } const offset = range.replace(newParts) / 2; const atEnd = range.getLastStartingPosition().asOffset(model).atNodeEnd; - const previousCaretOffset = range.getLastStartingPosition().asOffset(model).add(offset, atEnd); - return previousCaretOffset.asPosition(model); + const newStart = range.getLastStartingPosition().asOffset(model).add(offset, atEnd).asPosition(model); + range.setLastStartingPosition(newStart); + return range.getLastStartingPosition(); }); } @@ -127,21 +128,17 @@ const isPlainWord = (offset: number, part: Part) => { && part.type !== Type.Newline && part.type === Type.Plain; }; -export function getRangeOfWordAtCaretPosition(range: Range): Range { +export function selectRangeOfWordAtCaret(range: Range): void { // Select right side of word range.expandForwardsWhile((_index, offset, part) => { return isPlainWord(offset, part); }); - // Select left side of word range.expandBackwardsWhile((_index, offset, part) => { return isPlainWord(offset, part); }); - // Trim possibly selected new lines range.trim(); - - return range; } export function rangeStartsAtBeginningOfLine(range: Range): boolean { diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index b6ed2feb0d6..eaa330e8278 100644 --- a/test/editor/operations-test.js +++ b/test/editor/operations-test.js @@ -19,7 +19,7 @@ import EditorModel from "../../src/editor/model"; import { createPartCreator, createRenderer } from "./mock"; import { toggleInlineFormat, - getRangeOfWordAtCaretPosition, + selectRangeOfWordAtCaret, formatRange, } from "../../src/editor/operations"; import { Formatting } from "../../src/components/views/rooms/MessageComposerFormatBar"; @@ -193,7 +193,7 @@ describe('editor/operations: formatting operations', () => { ]); }); - it('format word at caret position at beginning of new line without selection', () => { + it('format word at caret position at beginning of new line without previous selection', () => { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([ @@ -201,18 +201,16 @@ describe('editor/operations: formatting operations', () => { pc.plain("hello!"), ], pc, renderer); - let range = model.startRange(model.positionForOffset(2, false)); + let range = model.startRange(model.positionForOffset(1, false)); // Initial position should equal start and end since we did not select anything - expect(range.getLastStartingPosition()).toEqual(range.start); - expect(range.getLastStartingPosition()).toEqual(range.end); - - range = getRangeOfWordAtCaretPosition(range); + expect(range.getLastStartingPosition()).toBe(range.start); + expect(range.getLastStartingPosition()).toBe(range.end); - // Check whether selection has the correct length - expect(range.length).toBe("hello!".length); + formatRange(range, Formatting.Bold); // Toggle - formatRange(range, Formatting.Bold); + expect(range.getLastStartingPosition().index).toBe(1); // Without the final offset + expect(range.getLastStartingPosition().offset).toBe(2); expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, @@ -226,10 +224,11 @@ describe('editor/operations: formatting operations', () => { { "text": "hello!", "type": "plain" }, ]); + // Check if it also works for code as it uses toggleInlineFormatting only indirectly range = model.startRange(model.positionForOffset(1, false)); - getRangeOfWordAtCaretPosition(range); + selectRangeOfWordAtCaret(range); - formatRange(range, Formatting.Code); + formatRange(range, Formatting.Code); // Toggle expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, @@ -247,28 +246,36 @@ describe('editor/operations: formatting operations', () => { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([ - pc.plain("**hello!**"), + pc.plain("hello **hello!**"), pc.newline(), pc.plain("world"), ], pc, renderer); expect(model.serializeParts()).toEqual([ - { "text": "**hello!**", "type": "plain" }, + { "text": "hello **hello!**", "type": "plain" }, SERIALIZED_NEWLINE, { "text": "world", "type": "plain" }, ]); - const endOfFirstLine = 10; + const endOfFirstLine = 16; const range = model.startRange(model.positionForOffset(endOfFirstLine, true)); + expect(range.getLastStartingPosition()).toEqual(range.start); expect(range.getLastStartingPosition()).toEqual(range.end); - formatRange(range, Formatting.Bold); // Toggle formatRange(range, Formatting.Bold); // Untoggle + expect(range.start.index).toEqual(0); + expect(range.end.index).toEqual(0); + + expect(range.getLastStartingPosition().index).toEqual(0); + expect(range.getLastStartingPosition().offset).toEqual(12); + + formatRange(range, Formatting.Italics); // Untoggle + // We expected formatting to still happen in the first line as the caret should not jump down expect(model.serializeParts()).toEqual([ - { "text": "**hello!**", "type": "plain" }, + { "text": "hello _hello!_", "type": "plain" }, SERIALIZED_NEWLINE, { "text": "world", "type": "plain" }, ]); @@ -281,18 +288,18 @@ describe('editor/operations: formatting operations', () => { pc.plain("hello!"), pc.newline(), pc.newline(), - pc.plain("konnichiwa!"), + pc.plain("world!"), ], pc, renderer); const range = model.startRange(model.positionForOffset(8, false), model.getPositionAtEnd()); // select-all - expect(range.parts.map(p => p.text).join("")).toBe("konnichiwa!"); + expect(range.parts.map(p => p.text).join("")).toBe("world!"); expect(model.serializeParts()).toEqual([ { "text": "hello!", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - { "text": "konnichiwa!", "type": "plain" }, + { "text": "world!", "type": "plain" }, ]); formatRange(range, Formatting.InsertLink); @@ -300,7 +307,7 @@ describe('editor/operations: formatting operations', () => { { "text": "hello!", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - { "text": "[konnichiwa!]()", "type": "plain" }, + { "text": "[world!]()", "type": "plain" }, ]); }); @@ -308,21 +315,21 @@ describe('editor/operations: formatting operations', () => { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([ - pc.plain("hello!"), + pc.plain("int x = 1;"), pc.newline(), pc.newline(), - pc.plain("konnichiwa!"), + pc.plain("int y = 42;"), ], pc, renderer); const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all - expect(range.parts.map(p => p.text).join("")).toBe("hello!\n\nkonnichiwa!"); + expect(range.parts.map(p => p.text).join("")).toBe("int x = 1;\n\nint y = 42;"); expect(model.serializeParts()).toEqual([ - { "text": "hello!", "type": "plain" }, + { "text": "int x = 1;", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - { "text": "konnichiwa!", "type": "plain" }, + { "text": "int y = 42;", "type": "plain" }, ]); formatRange(range, Formatting.Code); @@ -330,16 +337,16 @@ describe('editor/operations: formatting operations', () => { expect(model.serializeParts()).toEqual([ { "text": "```", "type": "plain" }, SERIALIZED_NEWLINE, - { "text": "hello!", "type": "plain" }, + { "text": "int x = 1;", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - { "text": "konnichiwa!", "type": "plain" }, + { "text": "int y = 42;", "type": "plain" }, SERIALIZED_NEWLINE, { "text": "```", "type": "plain" }, ]); }); - it('format white space', () => { + it('does not format pure white space', () => { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([ @@ -349,7 +356,7 @@ describe('editor/operations: formatting operations', () => { pc.plain(" "), ], pc, renderer); - const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all + 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([ From 4f27d410f642b9b6379c178963536ae2f2a84693 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Tue, 9 Nov 2021 14:26:23 +0100 Subject: [PATCH 24/39] Respect punctuation --- src/editor/operations.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index a343bb17d6b..36be2b9bc34 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -123,8 +123,10 @@ export function replaceRangeAndAutoAdjustCaret( }); } +const punctuation = [".", ",", "?", "!"]; + const isPlainWord = (offset: number, part: Part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" + return part.text[offset] !== " " && !punctuation.includes(part.text[offset]) && part.type !== Type.Newline && part.type === Type.Plain; }; From 9cb8ebe037f51fe1ab9dd55331f0bd7a72ec1f88 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Tue, 9 Nov 2021 14:32:55 +0100 Subject: [PATCH 25/39] Revert to substr --- src/editor/operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 36be2b9bc34..b59595b207f 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -283,7 +283,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix if (isFormatted) { // remove prefix and suffix formatting string const partWithoutPrefix = parts[base].serialize(); - partWithoutPrefix.text = partWithoutPrefix.text.substring(prefix.length); + partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length); parts[base] = partCreator.deserializePart(partWithoutPrefix); const partWithoutSuffix = parts[index - 1].serialize(); From 1f5b24caafedcd83be7a55aed32435e5e8d1911f Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Tue, 9 Nov 2021 14:53:18 +0100 Subject: [PATCH 26/39] Allow punctuation again for consistency --- src/editor/operations.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index b59595b207f..261ac20d097 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -123,10 +123,8 @@ export function replaceRangeAndAutoAdjustCaret( }); } -const punctuation = [".", ",", "?", "!"]; - const isPlainWord = (offset: number, part: Part) => { - return part.text[offset] !== " " && !punctuation.includes(part.text[offset]) + return part.text[offset] !== " " && part.text[offset] !== "+" && part.type !== Type.Newline && part.type === Type.Plain; }; From 2c05a6136eceee2e6fd7c1106da14d230c853751 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Tue, 9 Nov 2021 14:57:22 +0100 Subject: [PATCH 27/39] Remove whitespace --- src/editor/operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 261ac20d097..657d7b507b4 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -124,7 +124,7 @@ export function replaceRangeAndAutoAdjustCaret( } const isPlainWord = (offset: number, part: Part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" + return part.text[offset] !== " " && part.text[offset] !== "+" && part.type !== Type.Newline && part.type === Type.Plain; }; From abc9134c744b1887faf43432a797999ae0b80081 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Tue, 9 Nov 2021 15:49:17 +0100 Subject: [PATCH 28/39] Add more comments and introduce constant for white space --- src/editor/operations.ts | 16 +++++++++------- test/editor/operations-test.js | 6 +++--- test/editor/range-test.js | 5 +++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 657d7b507b4..cd7d4025457 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -102,20 +102,22 @@ export function replaceRangeAndAutoAdjustCaret( ): void { const { model } = range; model.transform(() => { - // Shift the initialPosition + // Shift the initialPosition by the length amount of characters that are between the format string, if it exists if (rangeHasFormatting) { - const relativeOffset = range.getLastStartingPosition().offset - range.start.offset; // Always positive - if (range.length - relativeOffset < formatStringLength) { - const correctionOffset = (range.length - relativeOffset - formatStringLength); + // getLastStartingPosition will always be after range.start.offset, we get a positive delta + const relativeOffset = range.getLastStartingPosition().offset - range.start.offset; + if (relativeOffset < formatStringLength) { // Was the caret at the left format string? + const correctionOffset = formatStringLength - relativeOffset; const newStart = range.getLastStartingPosition().asOffset(model).add(correctionOffset); range.setLastStartingPosition(newStart.asPosition(model)); - } else if (relativeOffset < formatStringLength) { - const correctionOffset = formatStringLength - relativeOffset; + } + if (range.length - relativeOffset < formatStringLength) { // Was the caret at the right format string? + const correctionOffset = (range.length - relativeOffset - formatStringLength); const newStart = range.getLastStartingPosition().asOffset(model).add(correctionOffset); range.setLastStartingPosition(newStart.asPosition(model)); } } - const offset = range.replace(newParts) / 2; + const offset = range.replace(newParts) / 2; // Works since all formatting that can be toggled is symmetric const atEnd = range.getLastStartingPosition().asOffset(model).atNodeEnd; const newStart = range.getLastStartingPosition().asOffset(model).add(offset, atEnd).asPosition(model); range.setLastStartingPosition(newStart); diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index eaa330e8278..05e6188dea0 100644 --- a/test/editor/operations-test.js +++ b/test/editor/operations-test.js @@ -271,7 +271,7 @@ describe('editor/operations: formatting operations', () => { expect(range.getLastStartingPosition().index).toEqual(0); expect(range.getLastStartingPosition().offset).toEqual(12); - formatRange(range, Formatting.Italics); // Untoggle + formatRange(range, Formatting.Italics); // Toggle // We expected formatting to still happen in the first line as the caret should not jump down expect(model.serializeParts()).toEqual([ @@ -302,7 +302,7 @@ describe('editor/operations: formatting operations', () => { { "text": "world!", "type": "plain" }, ]); - formatRange(range, Formatting.InsertLink); + formatRange(range, Formatting.InsertLink); // Toggle expect(model.serializeParts()).toEqual([ { "text": "hello!", "type": "plain" }, SERIALIZED_NEWLINE, @@ -332,7 +332,7 @@ describe('editor/operations: formatting operations', () => { { "text": "int y = 42;", "type": "plain" }, ]); - formatRange(range, Formatting.Code); + formatRange(range, Formatting.Code); // Toggle expect(model.serializeParts()).toEqual([ { "text": "```", "type": "plain" }, diff --git a/test/editor/range-test.js b/test/editor/range-test.js index d2046b5f4a4..42ec6de60dc 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -108,15 +108,16 @@ describe('editor/range', function() { it('range trim just whitespace', () => { const renderer = createRenderer(); const pc = createPartCreator(); + const whitespace = " \n \n\n"; const model = new EditorModel([ - pc.plain(" \n \n\n"), + pc.plain(whitespace), ], pc, renderer); const range = model.startRange( model.positionForOffset(0, false), model.getPositionAtEnd(), ); - expect(range.text).toBe(" \n \n\n"); + expect(range.text).toBe(whitespace); range.trim(); expect(range.text).toBe(""); }); From 75822e7cf447e0531e46773309fed8fcb1808806 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Tue, 9 Nov 2021 16:16:48 +0100 Subject: [PATCH 29/39] Extract function for isPlainWord --- src/editor/operations.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index cd7d4025457..068673b6e46 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -125,20 +125,15 @@ export function replaceRangeAndAutoAdjustCaret( }); } -const isPlainWord = (offset: number, part: Part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" - && part.type !== Type.Newline && part.type === Type.Plain; +const isPlainWord = (_index: number, offset: number, part: Part) => { + return part.text[offset] !== " " && part.text[offset] !== "+" && part.type === Type.Plain; }; export function selectRangeOfWordAtCaret(range: Range): void { // Select right side of word - range.expandForwardsWhile((_index, offset, part) => { - return isPlainWord(offset, part); - }); + range.expandForwardsWhile(isPlainWord); // Select left side of word - range.expandBackwardsWhile((_index, offset, part) => { - return isPlainWord(offset, part); - }); + range.expandBackwardsWhile(isPlainWord); // Trim possibly selected new lines range.trim(); } From 7fd0c64443850025cc534bb70a9edbd82dd4b536 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Fri, 26 Nov 2021 15:25:25 +0100 Subject: [PATCH 30/39] Switch keybinding for macOS --- src/KeyBindingsDefaults.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index ef888bcf3e7..66c542032af 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -88,8 +88,8 @@ const messageComposerBindings = (): KeyBinding[] => { action: MessageComposerAction.FormatLink, keyCombo: { key: Key.K, - ctrlOrCmd: true, - altKey: true, + altKey: !isMac, + shiftKey: isMac, }, }, { From 0137fa4aa254ac2725a69a4a9f3e1771011fd6dd Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Fri, 26 Nov 2021 15:27:10 +0100 Subject: [PATCH 31/39] Fix equals Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 068673b6e46..421c831174f 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -38,7 +38,7 @@ export function formatRange(range: Range, action: Formatting): void { // 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) { + if (range.length === 0) { return; } From 4f40a1ee32aac7c2a3795ead8dee4697285aa56d Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Fri, 26 Nov 2021 15:39:39 +0100 Subject: [PATCH 32/39] Fix missing ctrlOrCmd --- src/KeyBindingsDefaults.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 66c542032af..963242238ea 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -88,6 +88,7 @@ const messageComposerBindings = (): KeyBinding[] => { action: MessageComposerAction.FormatLink, keyCombo: { key: Key.K, + ctrlOrCmd: true, altKey: !isMac, shiftKey: isMac, }, From f4c545b249bd6a8fc4c9a4d806f757c635dc172e Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 27 Nov 2021 12:11:26 +0100 Subject: [PATCH 33/39] Use alternative shortcut for now --- src/KeyBindingsDefaults.ts | 5 ++--- src/components/views/rooms/BasicMessageComposer.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 963242238ea..8e3ae783f50 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -87,10 +87,9 @@ const messageComposerBindings = (): KeyBinding[] => { { action: MessageComposerAction.FormatLink, keyCombo: { - key: Key.K, + key: Key.L, ctrlOrCmd: true, - altKey: !isMac, - shiftKey: isMac, + shiftKey: true, }, }, { diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 995ba987e7d..079865d8bd9 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -727,7 +727,7 @@ export default class BasicMessageEditor extends React.Component [Formatting.Italics]: ctrlShortcutLabel("I"), [Formatting.Code]: ctrlShortcutLabel("E"), [Formatting.Quote]: ctrlShortcutLabel(">"), - [Formatting.InsertLink]: ctrlShortcutLabel("K", false, true), + [Formatting.InsertLink]: ctrlShortcutLabel("L", true), }; const { completionIndex } = this.state; From 15dd865309d1555f0431c881b8dbb1514a4a8e1d Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 27 Nov 2021 12:52:33 +0100 Subject: [PATCH 34/39] Enable link untoggle --- src/editor/operations.ts | 73 ++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 421c831174f..8229afcf9c3 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -75,20 +75,20 @@ 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. - * Then positions the cursor with respect to the prefix or suffix length. + * 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 @@ -98,42 +98,41 @@ export function replaceRangeAndAutoAdjustCaret( range: Range, newParts: Part[], rangeHasFormatting = false, - formatStringLength = 0, + prefixLength = 0, + suffixLength = prefixLength, ): void { const { model } = range; - model.transform(() => { - // Shift the initialPosition by the length amount of characters that are between the format string, if it exists - if (rangeHasFormatting) { - // getLastStartingPosition will always be after range.start.offset, we get a positive delta - const relativeOffset = range.getLastStartingPosition().offset - range.start.offset; - if (relativeOffset < formatStringLength) { // Was the caret at the left format string? - const correctionOffset = formatStringLength - relativeOffset; - const newStart = range.getLastStartingPosition().asOffset(model).add(correctionOffset); - range.setLastStartingPosition(newStart.asPosition(model)); - } - if (range.length - relativeOffset < formatStringLength) { // Was the caret at the right format string? - const correctionOffset = (range.length - relativeOffset - formatStringLength); - const newStart = range.getLastStartingPosition().asOffset(model).add(correctionOffset); - range.setLastStartingPosition(newStart.asPosition(model)); - } + const lastStartingPosition = range.getLastStartingPosition(); + const relativeOffset = lastStartingPosition.offset - range.start.offset; + const distanceFromEnd = range.length - relativeOffset; + // Handle edge case were 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; } - const offset = range.replace(newParts) / 2; // Works since all formatting that can be toggled is symmetric - const atEnd = range.getLastStartingPosition().asOffset(model).atNodeEnd; - const newStart = range.getLastStartingPosition().asOffset(model).add(offset, atEnd).asPosition(model); - range.setLastStartingPosition(newStart); - return range.getLastStartingPosition(); + } + // 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 isPlainWord = (_index: number, offset: number, part: Part) => { +const isSameWord = (_index: number, offset: number, part: Part) => { return part.text[offset] !== " " && part.text[offset] !== "+" && part.type === Type.Plain; }; export function selectRangeOfWordAtCaret(range: Range): void { // Select right side of word - range.expandForwardsWhile(isPlainWord); + range.expandForwardsWhile(isSameWord); // Select left side of word - range.expandBackwardsWhile(isPlainWord); + range.expandBackwardsWhile(isSameWord); // Trim possibly selected new lines range.trim(); } @@ -218,12 +217,20 @@ export function formatRangeAsCode(range: Range): void { } 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 From 1e96f137c1cd8979918dcad05d3650be80597ff1 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Sat, 27 Nov 2021 13:03:14 +0100 Subject: [PATCH 35/39] Update tests for different caret adjustment method --- test/editor/operations-test.js | 46 ++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index 05e6188dea0..ddf088a9bc0 100644 --- a/test/editor/operations-test.js +++ b/test/editor/operations-test.js @@ -209,9 +209,6 @@ describe('editor/operations: formatting operations', () => { formatRange(range, Formatting.Bold); // Toggle - expect(range.getLastStartingPosition().index).toBe(1); // Without the final offset - expect(range.getLastStartingPosition().offset).toBe(2); - expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, { "text": "**hello!**", "type": "plain" }, @@ -260,20 +257,10 @@ describe('editor/operations: formatting operations', () => { const endOfFirstLine = 16; const range = model.startRange(model.positionForOffset(endOfFirstLine, true)); - expect(range.getLastStartingPosition()).toEqual(range.start); - expect(range.getLastStartingPosition()).toEqual(range.end); - formatRange(range, Formatting.Bold); // Untoggle - - expect(range.start.index).toEqual(0); - expect(range.end.index).toEqual(0); - - expect(range.getLastStartingPosition().index).toEqual(0); - expect(range.getLastStartingPosition().offset).toEqual(12); - formatRange(range, Formatting.Italics); // Toggle - // We expected formatting to still happen in the first line as the caret should not jump down + // 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, @@ -287,27 +274,34 @@ describe('editor/operations: formatting operations', () => { const model = new EditorModel([ pc.plain("hello!"), pc.newline(), - pc.newline(), pc.plain("world!"), + pc.newline(), ], pc, renderer); - const range = model.startRange(model.positionForOffset(8, false), model.getPositionAtEnd()); // select-all - - expect(range.parts.map(p => p.text).join("")).toBe("world!"); + let range = model.startRange(model.getPositionAtEnd().asOffset(model).add(-1).asPosition(model)); // select-all expect(model.serializeParts()).toEqual([ { "text": "hello!", "type": "plain" }, SERIALIZED_NEWLINE, - SERIALIZED_NEWLINE, { "text": "world!", "type": "plain" }, + SERIALIZED_NEWLINE, ]); formatRange(range, Formatting.InsertLink); // Toggle expect(model.serializeParts()).toEqual([ { "text": "hello!", "type": "plain" }, SERIALIZED_NEWLINE, - 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, ]); }); @@ -321,7 +315,7 @@ describe('editor/operations: formatting operations', () => { pc.plain("int y = 42;"), ], pc, renderer); - const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all + 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;"); @@ -344,6 +338,16 @@ describe('editor/operations: formatting operations', () => { 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', () => { From 8fa3406a5ae0284736bda4f2f9e785c5571343b3 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 27 Dec 2021 21:40:23 +0100 Subject: [PATCH 36/39] Fix signatures and remove auto selection from quote --- src/editor/operations.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 8229afcf9c3..726ee9ae9e9 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -92,20 +92,21 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset * @param {Range} range the previous value * @param {Part[]} newParts the new value * @param {boolean} rangeHasFormatting the new value - * @param {number} formatStringLength the length of the format string, assumed to be 0 when not formatted + * @param {number} prefixLength length of the formatting prefix + * @param {number} suffixLength length of the formatting suffix */ export function replaceRangeAndAutoAdjustCaret( range: Range, newParts: Part[], rangeHasFormatting = false, - prefixLength = 0, + 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 were the caret is located within the suffix or prefix + // 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)); @@ -124,15 +125,15 @@ export function replaceRangeAndAutoAdjustCaret( }); } -const isSameWord = (_index: number, offset: number, part: Part) => { - return part.text[offset] !== " " && part.text[offset] !== "+" && part.type === Type.Plain; +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(isSameWord); + range.expandForwardsWhile(isFormattable); // Select left side of word - range.expandBackwardsWhile(isSameWord); + range.expandBackwardsWhile(isFormattable); // Trim possibly selected new lines range.trim(); } @@ -171,12 +172,7 @@ export function formatRangeAsQuote(range: Range): void { parts.push(partCreator.newline()); } parts.push(partCreator.newline()); - - if (range.wasInitializedEmpty()) { - replaceRangeAndAutoAdjustCaret(range, parts, false); - } else { - replaceRangeAndExpandSelection(range, parts); - } + replaceRangeAndExpandSelection(range, parts); } export function formatRangeAsCode(range: Range): void { From 2093e08e2666e4f4389f523d1f511f1be4bc9a46 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Mon, 27 Dec 2021 21:44:19 +0100 Subject: [PATCH 37/39] Fix comment style --- src/editor/operations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 726ee9ae9e9..93aa5165a24 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -92,8 +92,8 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset * @param {Range} range the previous value * @param {Part[]} newParts the new value * @param {boolean} rangeHasFormatting the new value - * @param {number} prefixLength length of the formatting prefix - * @param {number} suffixLength length of the formatting suffix + * @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, From 1e131b9c23133244963fa10573a1c631f5d48609 Mon Sep 17 00:00:00 2001 From: Alexander Stephan Date: Thu, 6 Jan 2022 21:12:25 +0100 Subject: [PATCH 38/39] Fix equals Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 93aa5165a24..f8d4a7b2c4c 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -189,7 +189,7 @@ export function formatRangeAsCode(range: Range): void { // Remove previously pushed backticks and new lines parts.shift(); parts.pop(); - if (parts[0]?.text == "\n" && parts[parts.length - 1]?.text == "\n") { + if (parts[0]?.text === "\n" && parts[parts.length - 1]?.text === "\n") { parts.shift(); parts.pop(); } From fd0befb73392e3244bd2124ba0742f3439df7153 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Feb 2022 08:22:38 +0000 Subject: [PATCH 39/39] Merge fixup, document shortcuts and fix mac-specific alt/shift mixup --- src/accessibility/KeyboardShortcuts.ts | 23 ++++++++++++++++++- .../views/rooms/BasicMessageComposer.tsx | 4 ++-- src/i18n/strings/en_EN.json | 2 ++ 3 files changed, 26 insertions(+), 3 deletions(-) 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 7e5f468728c..2f27e2613a2 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -526,7 +526,7 @@ export default class BasicMessageEditor extends React.Component this.onFormatAction(Formatting.Italics); handled = true; break; - case MessageComposerAction.FormatCode: + case KeyBindingAction.FormatCode: this.onFormatAction(Formatting.Code); handled = true; break; @@ -534,7 +534,7 @@ export default class BasicMessageEditor extends React.Component this.onFormatAction(Formatting.Quote); handled = true; break; - case MessageComposerAction.FormatLink: + case KeyBindingAction.FormatLink: this.onFormatAction(Formatting.InsertLink); handled = true; break; 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",