Skip to content

Commit

Permalink
Merge pull request #6353 from SimonBrandner/feature/improved-composer
Browse files Browse the repository at this point in the history
Improve handling of pills in the composer
  • Loading branch information
turt2live committed Aug 11, 2021
2 parents c758f7d + 49f4149 commit a149108
Show file tree
Hide file tree
Showing 17 changed files with 247 additions and 189 deletions.
8 changes: 8 additions & 0 deletions res/css/views/rooms/_BasicMessageComposer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ limitations under the License.
font-size: $font-10-4px;
}
}

span.mx_UserPill {
cursor: pointer;
}

span.mx_RoomPill {
cursor: default;
}
}

&.mx_BasicMessageComposer_input_disabled {
Expand Down
2 changes: 1 addition & 1 deletion src/ActiveRoomObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import RoomViewStore from './stores/RoomViewStore';
import { EventSubscription } from 'fbemitter';
import RoomViewStore from './stores/RoomViewStore';

type Listener = (isActive: boolean) => void;

Expand Down
34 changes: 29 additions & 5 deletions src/components/views/rooms/BasicMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator } 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";
Expand Down Expand Up @@ -169,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) {
Expand Down Expand Up @@ -541,6 +541,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this.formatBarRef.current.hide();
handled = this.fakeDeletion(event.key === Key.BACKSPACE);
}

if (handled) {
Expand All @@ -549,6 +550,29 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
};

/**
* Because pills have contentEditable="false" there is no event emitted when
* the user tries to delete them. Therefore we need to fake what would
* normally happen
* @param direction in which to delete
* @returns handled
*/
private fakeDeletion(backward: boolean): boolean {
const selection = document.getSelection();
// Use the default handling for ranges
if (selection.type === "Range") return false;

this.modifiedFlag = true;
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, selection);

// Do the deletion itself
if (backward) caret.offset--;
const newText = text.slice(0, caret.offset) + text.slice(caret.offset + 1);

this.props.model.update(newText, backward ? "deleteContentBackward" : "deleteContentForward", caret);
return true;
}

private async tabCompleteName(): Promise<void> {
try {
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
Expand All @@ -558,9 +582,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"
part.type === Type.Plain ||
part.type === Type.PillCandidate ||
part.type === Type.Command
);
});
const { partCreator } = model;
Expand Down
8 changes: 4 additions & 4 deletions src/components/views/rooms/EditMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
Expand Down Expand Up @@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}

if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
Expand All @@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
if (part.type === Type.UserPill) {
return text + part.resourceId;
}
return text + part.text;
Expand Down
6 changes: 3 additions & 3 deletions src/components/views/rooms/SendMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
Expand Down Expand Up @@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
// be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send
// a command as a message
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
Expand Down
24 changes: 12 additions & 12 deletions src/editor/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,35 +43,35 @@ export default class AutocompleteWrapperModel {
) {
}

public onEscape(e: KeyboardEvent) {
public onEscape(e: KeyboardEvent): void {
this.getAutocompleterComponent().onEscape(e);
this.updateCallback({
replaceParts: [this.partCreator.plain(this.queryPart.text)],
close: true,
});
}

public close() {
public close(): void {
this.updateCallback({ close: true });
}

public hasSelection() {
public hasSelection(): boolean {
return this.getAutocompleterComponent().hasSelection();
}

public hasCompletions() {
public hasCompletions(): boolean {
const ac = this.getAutocompleterComponent();
return ac && ac.countCompletions() > 0;
}

public onEnter() {
public onEnter(): void {
this.updateCallback({ close: true });
}

/**
* If there is no current autocompletion, start one and move to the first selection.
*/
public async startSelection() {
public async startSelection(): Promise<void> {
const acComponent = this.getAutocompleterComponent();
if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered
Expand All @@ -81,23 +81,23 @@ export default class AutocompleteWrapperModel {
}
}

public selectPreviousSelection() {
public selectPreviousSelection(): void {
this.getAutocompleterComponent().moveSelection(-1);
}

public selectNextSelection() {
public selectNextSelection(): void {
this.getAutocompleterComponent().moveSelection(+1);
}

public onPartUpdate(part: Part, pos: DocumentPosition) {
public onPartUpdate(part: Part, pos: DocumentPosition): Promise<void> {
// cache the typed value and caret here
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
this.queryPart = part;
this.partIndex = pos.index;
return this.updateQuery(part.text);
}

public onComponentSelectionChange(completion: ICompletion) {
public onComponentSelectionChange(completion: ICompletion): void {
if (!completion) {
this.updateCallback({
replaceParts: [this.queryPart],
Expand All @@ -109,14 +109,14 @@ export default class AutocompleteWrapperModel {
}
}

public onComponentConfirm(completion: ICompletion) {
public onComponentConfirm(completion: ICompletion): void {
this.updateCallback({
replaceParts: this.partForCompletion(completion),
close: true,
});
}

private partForCompletion(completion: ICompletion) {
private partForCompletion(completion: ICompletion): Part[] {
const { completionId } = completion;
const text = completion.completion;
switch (completion.type) {
Expand Down
6 changes: 3 additions & 3 deletions src/editor/caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { needsCaretNodeBefore, needsCaretNodeAfter } from "./render";
import Range from "./range";
import EditorModel from "./model";
import DocumentPosition, { IPosition } from "./position";
import { Part } from "./parts";
import { Part, Type } from "./parts";

export type Caret = Range | DocumentPosition;

Expand Down Expand Up @@ -113,7 +113,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// to find newline parts
for (let i = 0; i <= partIndex; ++i) {
const part = parts[i];
if (part.type === "newline") {
if (part.type === Type.Newline) {
lineIndex += 1;
nodeIndex = -1;
prevPart = null;
Expand All @@ -128,7 +128,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// and not an adjacent caret node
if (i < partIndex) {
const nextPart = parts[i + 1];
const isLastOfLine = !nextPart || nextPart.type === "newline";
const isLastOfLine = !nextPart || nextPart.type === Type.Newline;
if (needsCaretNodeAfter(part, isLastOfLine)) {
nodeIndex += 1;
}
Expand Down
4 changes: 2 additions & 2 deletions src/editor/deserialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { walkDOMDepthFirst } from "./dom";
import { checkBlockNode } from "../HtmlUtils";
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
import { PartCreator } from "./parts";
import { PartCreator, Type } from "./parts";
import SdkConfig from "../SdkConfig";

function parseAtRoomMentions(text: string, partCreator: PartCreator) {
Expand Down Expand Up @@ -206,7 +206,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) {
parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX));
}
for (let i = 0; i < parts.length; i += 1) {
if (parts[i].type === "newline") {
if (parts[i].type === Type.Newline) {
parts.splice(i + 1, 0, partCreator.plain(QUOTE_LINE_PREFIX));
i += 1;
}
Expand Down
2 changes: 1 addition & 1 deletion src/editor/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface IDiff {
at?: number;
}

function firstDiff(a: string, b: string) {
function firstDiff(a: string, b: string): number {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) {
Expand Down
14 changes: 7 additions & 7 deletions src/editor/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default class HistoryManager {
private addedSinceLastPush = false;
private removedSinceLastPush = false;

clear() {
public clear(): void {
this.stack = [];
this.newlyTypedCharCount = 0;
this.currentIndex = -1;
Expand Down Expand Up @@ -103,7 +103,7 @@ export default class HistoryManager {
}

// needs to persist parts and caret position
tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff) {
public tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff): boolean {
// ignore state restoration echos.
// these respect the inputType values of the input event,
// but are actually passed in from MessageEditor calling model.reset()
Expand All @@ -121,22 +121,22 @@ export default class HistoryManager {
return shouldPush;
}

ensureLastChangesPushed(model: EditorModel) {
public ensureLastChangesPushed(model: EditorModel): void {
if (this.changedSinceLastPush) {
this.pushState(model, this.lastCaret);
}
}

canUndo() {
public canUndo(): boolean {
return this.currentIndex >= 1 || this.changedSinceLastPush;
}

canRedo() {
public canRedo(): boolean {
return this.currentIndex < (this.stack.length - 1);
}

// returns state that should be applied to model
undo(model: EditorModel) {
public undo(model: EditorModel): IHistory {
if (this.canUndo()) {
this.ensureLastChangesPushed(model);
this.currentIndex -= 1;
Expand All @@ -145,7 +145,7 @@ export default class HistoryManager {
}

// returns state that should be applied to model
redo() {
public redo(): IHistory {
if (this.canRedo()) {
this.changedSinceLastPush = false;
this.currentIndex += 1;
Expand Down
5 changes: 3 additions & 2 deletions src/editor/offset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ limitations under the License.
*/

import EditorModel from "./model";
import DocumentPosition from "./position";

export default class DocumentOffset {
constructor(public offset: number, public readonly atNodeEnd: boolean) {
}

asPosition(model: EditorModel) {
public asPosition(model: EditorModel): DocumentPosition {
return model.positionForOffset(this.offset, this.atNodeEnd);
}

add(delta: number, atNodeEnd = false) {
public add(delta: number, atNodeEnd = false): DocumentOffset {
return new DocumentOffset(this.offset + delta, atNodeEnd);
}
}
Loading

0 comments on commit a149108

Please sign in to comment.