Merge pull request #6353 from SimonBrandner/feature/improved-composer

Improve handling of pills in the composer
This commit is contained in:
Travis Ralston 2021-08-11 10:55:13 -06:00 committed by GitHub
commit a149108a7d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 247 additions and 189 deletions

View file

@ -65,6 +65,14 @@ limitations under the License.
font-size: $font-10-4px; font-size: $font-10-4px;
} }
} }
span.mx_UserPill {
cursor: pointer;
}
span.mx_RoomPill {
cursor: default;
}
} }
&.mx_BasicMessageComposer_input_disabled { &.mx_BasicMessageComposer_input_disabled {

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import RoomViewStore from './stores/RoomViewStore';
import { EventSubscription } from 'fbemitter'; import { EventSubscription } from 'fbemitter';
import RoomViewStore from './stores/RoomViewStore';
type Listener = (isActive: boolean) => void; type Listener = (isActive: boolean) => void;

View file

@ -31,7 +31,7 @@ import {
} from '../../../editor/operations'; } from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom'; import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator } from '../../../editor/parts'; import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render'; import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore"; import TypingStore from "../../../stores/TypingStore";
@ -169,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => { range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index]; const part = model.parts[index];
n -= 1; 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); const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) { if (emoticonMatch) {
@ -541,6 +541,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
handled = true; handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this.formatBarRef.current.hide(); this.formatBarRef.current.hide();
handled = this.fakeDeletion(event.key === Key.BACKSPACE);
} }
if (handled) { if (handled) {
@ -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> { private async tabCompleteName(): Promise<void> {
try { try {
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve)); await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
@ -558,9 +582,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const range = model.startRange(position); const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => { range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && part.text[offset] !== "+" && ( return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" || part.type === Type.Plain ||
part.type === "pill-candidate" || part.type === Type.PillCandidate ||
part.type === "command" part.type === Type.Command
); );
}); });
const { partCreator } = model; const { partCreator } = model;

View file

@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize'; import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize'; 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 EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const parts = this.model.parts; const parts = this.model.parts;
const firstPart = parts[0]; const firstPart = parts[0];
if (firstPart) { 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; return true;
} }
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//") if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) { && (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true; return true;
} }
} }
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private getSlashCommand(): [Command, string, string] { private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => { const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command // 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.resourceId;
} }
return text + part.text; return text + part.text;

View file

@ -31,7 +31,7 @@ import {
textSerialize, textSerialize,
unescapeMessage, unescapeMessage,
} from '../../../editor/serialize'; } 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 BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
const parts = this.model.parts; const parts = this.model.parts;
const firstPart = parts[0]; const firstPart = parts[0];
if (firstPart) { 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; return true;
} }
// be extra resilient when somehow the AutocompleteWrapperModel or // be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send // CommandPartCreator fails to insert a command part, so we don't send
// a command as a message // a command as a message
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//") if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) { && (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true; return true;
} }
} }

View file

@ -43,7 +43,7 @@ export default class AutocompleteWrapperModel {
) { ) {
} }
public onEscape(e: KeyboardEvent) { public onEscape(e: KeyboardEvent): void {
this.getAutocompleterComponent().onEscape(e); this.getAutocompleterComponent().onEscape(e);
this.updateCallback({ this.updateCallback({
replaceParts: [this.partCreator.plain(this.queryPart.text)], replaceParts: [this.partCreator.plain(this.queryPart.text)],
@ -51,27 +51,27 @@ export default class AutocompleteWrapperModel {
}); });
} }
public close() { public close(): void {
this.updateCallback({ close: true }); this.updateCallback({ close: true });
} }
public hasSelection() { public hasSelection(): boolean {
return this.getAutocompleterComponent().hasSelection(); return this.getAutocompleterComponent().hasSelection();
} }
public hasCompletions() { public hasCompletions(): boolean {
const ac = this.getAutocompleterComponent(); const ac = this.getAutocompleterComponent();
return ac && ac.countCompletions() > 0; return ac && ac.countCompletions() > 0;
} }
public onEnter() { public onEnter(): void {
this.updateCallback({ close: true }); this.updateCallback({ close: true });
} }
/** /**
* If there is no current autocompletion, start one and move to the first selection. * 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(); const acComponent = this.getAutocompleterComponent();
if (acComponent.countCompletions() === 0) { if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered // Force completions to show for the text currently entered
@ -81,15 +81,15 @@ export default class AutocompleteWrapperModel {
} }
} }
public selectPreviousSelection() { public selectPreviousSelection(): void {
this.getAutocompleterComponent().moveSelection(-1); this.getAutocompleterComponent().moveSelection(-1);
} }
public selectNextSelection() { public selectNextSelection(): void {
this.getAutocompleterComponent().moveSelection(+1); 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 // 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) // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
this.queryPart = part; this.queryPart = part;
@ -97,7 +97,7 @@ export default class AutocompleteWrapperModel {
return this.updateQuery(part.text); return this.updateQuery(part.text);
} }
public onComponentSelectionChange(completion: ICompletion) { public onComponentSelectionChange(completion: ICompletion): void {
if (!completion) { if (!completion) {
this.updateCallback({ this.updateCallback({
replaceParts: [this.queryPart], replaceParts: [this.queryPart],
@ -109,14 +109,14 @@ export default class AutocompleteWrapperModel {
} }
} }
public onComponentConfirm(completion: ICompletion) { public onComponentConfirm(completion: ICompletion): void {
this.updateCallback({ this.updateCallback({
replaceParts: this.partForCompletion(completion), replaceParts: this.partForCompletion(completion),
close: true, close: true,
}); });
} }
private partForCompletion(completion: ICompletion) { private partForCompletion(completion: ICompletion): Part[] {
const { completionId } = completion; const { completionId } = completion;
const text = completion.completion; const text = completion.completion;
switch (completion.type) { switch (completion.type) {

View file

@ -19,7 +19,7 @@ import { needsCaretNodeBefore, needsCaretNodeAfter } from "./render";
import Range from "./range"; import Range from "./range";
import EditorModel from "./model"; import EditorModel from "./model";
import DocumentPosition, { IPosition } from "./position"; import DocumentPosition, { IPosition } from "./position";
import { Part } from "./parts"; import { Part, Type } from "./parts";
export type Caret = Range | DocumentPosition; export type Caret = Range | DocumentPosition;
@ -113,7 +113,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// to find newline parts // to find newline parts
for (let i = 0; i <= partIndex; ++i) { for (let i = 0; i <= partIndex; ++i) {
const part = parts[i]; const part = parts[i];
if (part.type === "newline") { if (part.type === Type.Newline) {
lineIndex += 1; lineIndex += 1;
nodeIndex = -1; nodeIndex = -1;
prevPart = null; prevPart = null;
@ -128,7 +128,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// and not an adjacent caret node // and not an adjacent caret node
if (i < partIndex) { if (i < partIndex) {
const nextPart = parts[i + 1]; const nextPart = parts[i + 1];
const isLastOfLine = !nextPart || nextPart.type === "newline"; const isLastOfLine = !nextPart || nextPart.type === Type.Newline;
if (needsCaretNodeAfter(part, isLastOfLine)) { if (needsCaretNodeAfter(part, isLastOfLine)) {
nodeIndex += 1; nodeIndex += 1;
} }

View file

@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { walkDOMDepthFirst } from "./dom"; import { walkDOMDepthFirst } from "./dom";
import { checkBlockNode } from "../HtmlUtils"; import { checkBlockNode } from "../HtmlUtils";
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks"; import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
import { PartCreator } from "./parts"; import { PartCreator, Type } from "./parts";
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
function parseAtRoomMentions(text: string, partCreator: PartCreator) { function parseAtRoomMentions(text: string, partCreator: PartCreator) {
@ -206,7 +206,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) {
parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX)); parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX));
} }
for (let i = 0; i < parts.length; i += 1) { 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)); parts.splice(i + 1, 0, partCreator.plain(QUOTE_LINE_PREFIX));
i += 1; i += 1;
} }

View file

@ -21,7 +21,7 @@ export interface IDiff {
at?: number; at?: number;
} }
function firstDiff(a: string, b: string) { function firstDiff(a: string, b: string): number {
const compareLen = Math.min(a.length, b.length); const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) { for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) { if (a[i] !== b[i]) {

View file

@ -36,7 +36,7 @@ export default class HistoryManager {
private addedSinceLastPush = false; private addedSinceLastPush = false;
private removedSinceLastPush = false; private removedSinceLastPush = false;
clear() { public clear(): void {
this.stack = []; this.stack = [];
this.newlyTypedCharCount = 0; this.newlyTypedCharCount = 0;
this.currentIndex = -1; this.currentIndex = -1;
@ -103,7 +103,7 @@ export default class HistoryManager {
} }
// needs to persist parts and caret position // 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. // ignore state restoration echos.
// these respect the inputType values of the input event, // these respect the inputType values of the input event,
// but are actually passed in from MessageEditor calling model.reset() // but are actually passed in from MessageEditor calling model.reset()
@ -121,22 +121,22 @@ export default class HistoryManager {
return shouldPush; return shouldPush;
} }
ensureLastChangesPushed(model: EditorModel) { public ensureLastChangesPushed(model: EditorModel): void {
if (this.changedSinceLastPush) { if (this.changedSinceLastPush) {
this.pushState(model, this.lastCaret); this.pushState(model, this.lastCaret);
} }
} }
canUndo() { public canUndo(): boolean {
return this.currentIndex >= 1 || this.changedSinceLastPush; return this.currentIndex >= 1 || this.changedSinceLastPush;
} }
canRedo() { public canRedo(): boolean {
return this.currentIndex < (this.stack.length - 1); return this.currentIndex < (this.stack.length - 1);
} }
// returns state that should be applied to model // returns state that should be applied to model
undo(model: EditorModel) { public undo(model: EditorModel): IHistory {
if (this.canUndo()) { if (this.canUndo()) {
this.ensureLastChangesPushed(model); this.ensureLastChangesPushed(model);
this.currentIndex -= 1; this.currentIndex -= 1;
@ -145,7 +145,7 @@ export default class HistoryManager {
} }
// returns state that should be applied to model // returns state that should be applied to model
redo() { public redo(): IHistory {
if (this.canRedo()) { if (this.canRedo()) {
this.changedSinceLastPush = false; this.changedSinceLastPush = false;
this.currentIndex += 1; this.currentIndex += 1;

View file

@ -15,16 +15,17 @@ limitations under the License.
*/ */
import EditorModel from "./model"; import EditorModel from "./model";
import DocumentPosition from "./position";
export default class DocumentOffset { export default class DocumentOffset {
constructor(public offset: number, public readonly atNodeEnd: boolean) { constructor(public offset: number, public readonly atNodeEnd: boolean) {
} }
asPosition(model: EditorModel) { public asPosition(model: EditorModel): DocumentPosition {
return model.positionForOffset(this.offset, this.atNodeEnd); 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); return new DocumentOffset(this.offset + delta, atNodeEnd);
} }
} }

View file

@ -15,13 +15,13 @@ limitations under the License.
*/ */
import Range from "./range"; import Range from "./range";
import { Part } from "./parts"; import { Part, Type } from "./parts";
/** /**
* Some common queries and transformations on the editor model * Some common queries and transformations on the editor model
*/ */
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) { export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void {
const { model } = range; const { model } = range;
model.transform(() => { model.transform(() => {
const oldLen = range.length; const oldLen = range.length;
@ -32,7 +32,7 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
}); });
} }
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) { export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]): void {
const { model } = range; const { model } = range;
model.transform(() => { model.transform(() => {
const oldLen = range.length; const oldLen = range.length;
@ -43,29 +43,29 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) {
}); });
} }
export function rangeStartsAtBeginningOfLine(range: Range) { export function rangeStartsAtBeginningOfLine(range: Range): boolean {
const { model } = range; const { model } = range;
const startsWithPartial = range.start.offset !== 0; const startsWithPartial = range.start.offset !== 0;
const isFirstPart = range.start.index === 0; const isFirstPart = range.start.index === 0;
const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === "newline"; const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === Type.Newline;
return !startsWithPartial && (isFirstPart || previousIsNewline); return !startsWithPartial && (isFirstPart || previousIsNewline);
} }
export function rangeEndsAtEndOfLine(range: Range) { export function rangeEndsAtEndOfLine(range: Range): boolean {
const { model } = range; const { model } = range;
const lastPart = model.parts[range.end.index]; const lastPart = model.parts[range.end.index];
const endsWithPartial = range.end.offset !== lastPart.text.length; const endsWithPartial = range.end.offset !== lastPart.text.length;
const isLastPart = range.end.index === model.parts.length - 1; const isLastPart = range.end.index === model.parts.length - 1;
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline"; const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === Type.Newline;
return !endsWithPartial && (isLastPart || nextIsNewline); return !endsWithPartial && (isLastPart || nextIsNewline);
} }
export function formatRangeAsQuote(range: Range) { export function formatRangeAsQuote(range: Range): void {
const { model, parts } = range; const { model, parts } = range;
const { partCreator } = model; const { partCreator } = model;
for (let i = 0; i < parts.length; ++i) { for (let i = 0; i < parts.length; ++i) {
const part = parts[i]; const part = parts[i];
if (part.type === "newline") { if (part.type === Type.Newline) {
parts.splice(i + 1, 0, partCreator.plain("> ")); parts.splice(i + 1, 0, partCreator.plain("> "));
} }
} }
@ -81,10 +81,10 @@ export function formatRangeAsQuote(range: Range) {
replaceRangeAndExpandSelection(range, parts); replaceRangeAndExpandSelection(range, parts);
} }
export function formatRangeAsCode(range: Range) { export function formatRangeAsCode(range: Range): void {
const { model, parts } = range; const { model, parts } = range;
const { partCreator } = model; const { partCreator } = model;
const needsBlock = parts.some(p => p.type === "newline"); const needsBlock = parts.some(p => p.type === Type.Newline);
if (needsBlock) { if (needsBlock) {
parts.unshift(partCreator.plain("```"), partCreator.newline()); parts.unshift(partCreator.plain("```"), partCreator.newline());
if (!rangeStartsAtBeginningOfLine(range)) { if (!rangeStartsAtBeginningOfLine(range)) {
@ -105,9 +105,9 @@ export function formatRangeAsCode(range: Range) {
// parts helper methods // parts helper methods
const isBlank = part => !part.text || !/\S/.test(part.text); const isBlank = part => !part.text || !/\S/.test(part.text);
const isNL = part => part.type === "newline"; const isNL = part => part.type === Type.Newline;
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix) { export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix): void {
const { model, parts } = range; const { model, parts } = range;
const { partCreator } = model; const { partCreator } = model;

View file

@ -25,6 +25,8 @@ import AutocompleteWrapperModel, {
UpdateQuery, UpdateQuery,
} from "./autocomplete"; } from "./autocomplete";
import * as Avatar from "../Avatar"; import * as Avatar from "../Avatar";
import defaultDispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
interface ISerializedPart { interface ISerializedPart {
type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate; type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
@ -39,7 +41,7 @@ interface ISerializedPillPart {
export type SerializedPart = ISerializedPart | ISerializedPillPart; export type SerializedPart = ISerializedPart | ISerializedPillPart;
enum Type { export enum Type {
Plain = "plain", Plain = "plain",
Newline = "newline", Newline = "newline",
Command = "command", Command = "command",
@ -57,12 +59,12 @@ interface IBasePart {
createAutoComplete(updateCallback: UpdateCallback): void; createAutoComplete(updateCallback: UpdateCallback): void;
serialize(): SerializedPart; serialize(): SerializedPart;
remove(offset: number, len: number): string; remove(offset: number, len: number): string | undefined;
split(offset: number): IBasePart; split(offset: number): IBasePart;
validateAndInsert(offset: number, str: string, inputType: string): boolean; validateAndInsert(offset: number, str: string, inputType: string): boolean;
appendUntilRejected(str: string, inputType: string): string; appendUntilRejected(str: string, inputType: string): string | undefined;
updateDOMNode(node: Node); updateDOMNode(node: Node): void;
canUpdateDOMNode(node: Node); canUpdateDOMNode(node: Node): boolean;
toDOMNode(): Node; toDOMNode(): Node;
} }
@ -85,19 +87,19 @@ abstract class BasePart {
this._text = text; this._text = text;
} }
acceptsInsertion(chr: string, offset: number, inputType: string) { protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
return true; return true;
} }
acceptsRemoval(position: number, chr: string) { protected acceptsRemoval(position: number, chr: string): boolean {
return true; return true;
} }
merge(part: Part) { public merge(part: Part): boolean {
return false; return false;
} }
split(offset: number) { public split(offset: number): IBasePart {
const splitText = this.text.substr(offset); const splitText = this.text.substr(offset);
this._text = this.text.substr(0, offset); this._text = this.text.substr(0, offset);
return new PlainPart(splitText); return new PlainPart(splitText);
@ -105,7 +107,7 @@ abstract class BasePart {
// removes len chars, or returns the plain text this part should be replaced with // removes len chars, or returns the plain text this part should be replaced with
// if the part would become invalid if it removed everything. // if the part would become invalid if it removed everything.
remove(offset: number, len: number) { public remove(offset: number, len: number): string | undefined {
// validate // validate
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len); const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
for (let i = offset; i < (len + offset); ++i) { for (let i = offset; i < (len + offset); ++i) {
@ -118,7 +120,7 @@ abstract class BasePart {
} }
// append str, returns the remaining string if a character was rejected. // append str, returns the remaining string if a character was rejected.
appendUntilRejected(str: string, inputType: string) { public appendUntilRejected(str: string, inputType: string): string | undefined {
const offset = this.text.length; const offset = this.text.length;
for (let i = 0; i < str.length; ++i) { for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i); const chr = str.charAt(i);
@ -132,7 +134,7 @@ abstract class BasePart {
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything // inserts str at offset if all the characters in str were accepted, otherwise don't do anything
// return whether the str was accepted or not. // return whether the str was accepted or not.
validateAndInsert(offset: number, str: string, inputType: string) { public validateAndInsert(offset: number, str: string, inputType: string): boolean {
for (let i = 0; i < str.length; ++i) { for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i); const chr = str.charAt(i);
if (!this.acceptsInsertion(chr, offset + i, inputType)) { if (!this.acceptsInsertion(chr, offset + i, inputType)) {
@ -145,42 +147,42 @@ abstract class BasePart {
return true; return true;
} }
createAutoComplete(updateCallback: UpdateCallback): void {} public createAutoComplete(updateCallback: UpdateCallback): void {}
trim(len: number) { protected trim(len: number): string {
const remaining = this._text.substr(len); const remaining = this._text.substr(len);
this._text = this._text.substr(0, len); this._text = this._text.substr(0, len);
return remaining; return remaining;
} }
get text() { public get text(): string {
return this._text; return this._text;
} }
abstract get type(): Type; public abstract get type(): Type;
get canEdit() { public get canEdit(): boolean {
return true; return true;
} }
toString() { public toString(): string {
return `${this.type}(${this.text})`; return `${this.type}(${this.text})`;
} }
serialize(): SerializedPart { public serialize(): SerializedPart {
return { return {
type: this.type as ISerializedPart["type"], type: this.type as ISerializedPart["type"],
text: this.text, text: this.text,
}; };
} }
abstract updateDOMNode(node: Node); public abstract updateDOMNode(node: Node): void;
abstract canUpdateDOMNode(node: Node); public abstract canUpdateDOMNode(node: Node): boolean;
abstract toDOMNode(): Node; public abstract toDOMNode(): Node;
} }
abstract class PlainBasePart extends BasePart { abstract class PlainBasePart extends BasePart {
acceptsInsertion(chr: string, offset: number, inputType: string) { protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (chr === "\n") { if (chr === "\n") {
return false; return false;
} }
@ -203,11 +205,11 @@ abstract class PlainBasePart extends BasePart {
return true; return true;
} }
toDOMNode() { public toDOMNode(): Node {
return document.createTextNode(this.text); return document.createTextNode(this.text);
} }
merge(part) { public merge(part): boolean {
if (part.type === this.type) { if (part.type === this.type) {
this._text = this.text + part.text; this._text = this.text + part.text;
return true; return true;
@ -215,47 +217,49 @@ abstract class PlainBasePart extends BasePart {
return false; return false;
} }
updateDOMNode(node: Node) { public updateDOMNode(node: Node): void {
if (node.textContent !== this.text) { if (node.textContent !== this.text) {
node.textContent = this.text; node.textContent = this.text;
} }
} }
canUpdateDOMNode(node: Node) { public canUpdateDOMNode(node: Node): boolean {
return node.nodeType === Node.TEXT_NODE; return node.nodeType === Node.TEXT_NODE;
} }
} }
// exported for unit tests, should otherwise only be used through PartCreator // exported for unit tests, should otherwise only be used through PartCreator
export class PlainPart extends PlainBasePart implements IBasePart { export class PlainPart extends PlainBasePart implements IBasePart {
get type(): IBasePart["type"] { public get type(): IBasePart["type"] {
return Type.Plain; return Type.Plain;
} }
} }
abstract class PillPart extends BasePart implements IPillPart { export abstract class PillPart extends BasePart implements IPillPart {
constructor(public resourceId: string, label) { constructor(public resourceId: string, label) {
super(label); super(label);
} }
acceptsInsertion(chr: string) { protected acceptsInsertion(chr: string): boolean {
return chr !== " "; return chr !== " ";
} }
acceptsRemoval(position: number, chr: string) { protected acceptsRemoval(position: number, chr: string): boolean {
return position !== 0; //if you remove initial # or @, pill should become plain return position !== 0; //if you remove initial # or @, pill should become plain
} }
toDOMNode() { public toDOMNode(): Node {
const container = document.createElement("span"); const container = document.createElement("span");
container.setAttribute("spellcheck", "false"); container.setAttribute("spellcheck", "false");
container.setAttribute("contentEditable", "false");
container.onclick = this.onClick;
container.className = this.className; container.className = this.className;
container.appendChild(document.createTextNode(this.text)); container.appendChild(document.createTextNode(this.text));
this.setAvatar(container); this.setAvatar(container);
return container; return container;
} }
updateDOMNode(node: HTMLElement) { public updateDOMNode(node: HTMLElement): void {
const textNode = node.childNodes[0]; const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) { if (textNode.textContent !== this.text) {
textNode.textContent = this.text; textNode.textContent = this.text;
@ -263,10 +267,13 @@ abstract class PillPart extends BasePart implements IPillPart {
if (node.className !== this.className) { if (node.className !== this.className) {
node.className = this.className; node.className = this.className;
} }
if (node.onclick !== this.onClick) {
node.onclick = this.onClick;
}
this.setAvatar(node); this.setAvatar(node);
} }
canUpdateDOMNode(node: HTMLElement) { public canUpdateDOMNode(node: HTMLElement): boolean {
return node.nodeType === Node.ELEMENT_NODE && return node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === "SPAN" && node.nodeName === "SPAN" &&
node.childNodes.length === 1 && node.childNodes.length === 1 &&
@ -274,7 +281,7 @@ abstract class PillPart extends BasePart implements IPillPart {
} }
// helper method for subclasses // helper method for subclasses
protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) { protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string): void {
const avatarBackground = `url('${avatarUrl}')`; const avatarBackground = `url('${avatarUrl}')`;
const avatarLetter = `'${initialLetter}'`; const avatarLetter = `'${initialLetter}'`;
// check if the value is changing, // check if the value is changing,
@ -287,7 +294,7 @@ abstract class PillPart extends BasePart implements IPillPart {
} }
} }
serialize(): ISerializedPillPart { public serialize(): ISerializedPillPart {
return { return {
type: this.type, type: this.type,
text: this.text, text: this.text,
@ -295,41 +302,43 @@ abstract class PillPart extends BasePart implements IPillPart {
}; };
} }
get canEdit() { public get canEdit(): boolean {
return false; return false;
} }
abstract get type(): IPillPart["type"]; public abstract get type(): IPillPart["type"];
abstract get className(): string; protected abstract get className(): string;
abstract setAvatar(node: HTMLElement): void; protected onClick?: () => void;
protected abstract setAvatar(node: HTMLElement): void;
} }
class NewlinePart extends BasePart implements IBasePart { class NewlinePart extends BasePart implements IBasePart {
acceptsInsertion(chr: string, offset: number) { protected acceptsInsertion(chr: string, offset: number): boolean {
return offset === 0 && chr === "\n"; return offset === 0 && chr === "\n";
} }
acceptsRemoval(position: number, chr: string) { protected acceptsRemoval(position: number, chr: string): boolean {
return true; return true;
} }
toDOMNode() { public toDOMNode(): Node {
return document.createElement("br"); return document.createElement("br");
} }
merge() { public merge(): boolean {
return false; return false;
} }
updateDOMNode() {} public updateDOMNode(): void {}
canUpdateDOMNode(node: HTMLElement) { public canUpdateDOMNode(node: HTMLElement): boolean {
return node.tagName === "BR"; return node.tagName === "BR";
} }
get type(): IBasePart["type"] { public get type(): IBasePart["type"] {
return Type.Newline; return Type.Newline;
} }
@ -337,7 +346,7 @@ class NewlinePart extends BasePart implements IBasePart {
// rather than trying to append to it, which is what we want. // rather than trying to append to it, which is what we want.
// As a newline can also be only one character, it makes sense // As a newline can also be only one character, it makes sense
// as it can only be one character long. This caused #9741. // as it can only be one character long. This caused #9741.
get canEdit() { public get canEdit(): boolean {
return false; return false;
} }
} }
@ -347,7 +356,7 @@ class RoomPillPart extends PillPart {
super(resourceId, label); super(resourceId, label);
} }
setAvatar(node: HTMLElement) { protected setAvatar(node: HTMLElement): void {
let initialLetter = ""; let initialLetter = "";
let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop"); let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
if (!avatarUrl) { if (!avatarUrl) {
@ -357,11 +366,11 @@ class RoomPillPart extends PillPart {
this.setAvatarVars(node, avatarUrl, initialLetter); this.setAvatarVars(node, avatarUrl, initialLetter);
} }
get type(): IPillPart["type"] { public get type(): IPillPart["type"] {
return Type.RoomPill; return Type.RoomPill;
} }
get className() { protected get className() {
return "mx_RoomPill mx_Pill"; return "mx_RoomPill mx_Pill";
} }
} }
@ -371,11 +380,11 @@ class AtRoomPillPart extends RoomPillPart {
super(text, text, room); super(text, text, room);
} }
get type(): IPillPart["type"] { public get type(): IPillPart["type"] {
return Type.AtRoomPill; return Type.AtRoomPill;
} }
serialize(): ISerializedPillPart { public serialize(): ISerializedPillPart {
return { return {
type: this.type, type: this.type,
text: this.text, text: this.text,
@ -388,7 +397,15 @@ class UserPillPart extends PillPart {
super(userId, displayName); super(userId, displayName);
} }
setAvatar(node: HTMLElement) { public get type(): IPillPart["type"] {
return Type.UserPill;
}
protected get className() {
return "mx_UserPill mx_Pill";
}
protected setAvatar(node: HTMLElement): void {
if (!this.member) { if (!this.member) {
return; return;
} }
@ -402,13 +419,12 @@ class UserPillPart extends PillPart {
this.setAvatarVars(node, avatarUrl, initialLetter); this.setAvatarVars(node, avatarUrl, initialLetter);
} }
get type(): IPillPart["type"] { protected onClick = (): void => {
return Type.UserPill; defaultDispatcher.dispatch({
} action: Action.ViewUser,
member: this.member,
get className() { });
return "mx_UserPill mx_Pill"; };
}
} }
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart { class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
@ -416,11 +432,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
super(text); super(text);
} }
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel { public createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
return this.autoCompleteCreator.create(updateCallback); return this.autoCompleteCreator.create(updateCallback);
} }
acceptsInsertion(chr: string, offset: number, inputType: string) { protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (offset === 0) { if (offset === 0) {
return true; return true;
} else { } else {
@ -428,11 +444,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
} }
} }
merge() { public merge(): boolean {
return false; return false;
} }
acceptsRemoval(position: number, chr: string) { protected acceptsRemoval(position: number, chr: string): boolean {
return true; return true;
} }
@ -463,17 +479,21 @@ interface IAutocompleteCreator {
export class PartCreator { export class PartCreator {
protected readonly autoCompleteCreator: IAutocompleteCreator; protected readonly autoCompleteCreator: IAutocompleteCreator;
constructor(private room: Room, private client: MatrixClient, autoCompleteCreator: AutoCompleteCreator = null) { constructor(
private readonly room: Room,
private readonly client: MatrixClient,
autoCompleteCreator: AutoCompleteCreator = null,
) {
// pre-create the creator as an object even without callback so it can already be passed // pre-create the creator as an object even without callback so it can already be passed
// to PillCandidatePart (e.g. while deserializing) and set later on // to PillCandidatePart (e.g. while deserializing) and set later on
this.autoCompleteCreator = { create: autoCompleteCreator && autoCompleteCreator(this) }; this.autoCompleteCreator = { create: autoCompleteCreator?.(this) };
} }
setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator) { public setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator): void {
this.autoCompleteCreator.create = autoCompleteCreator(this); this.autoCompleteCreator.create = autoCompleteCreator(this);
} }
createPartForInput(input: string, partIndex: number, inputType?: string): Part { public createPartForInput(input: string, partIndex: number, inputType?: string): Part {
switch (input[0]) { switch (input[0]) {
case "#": case "#":
case "@": case "@":
@ -487,11 +507,11 @@ export class PartCreator {
} }
} }
createDefaultPart(text: string) { public createDefaultPart(text: string): Part {
return this.plain(text); return this.plain(text);
} }
deserializePart(part: SerializedPart): Part { public deserializePart(part: SerializedPart): Part {
switch (part.type) { switch (part.type) {
case Type.Plain: case Type.Plain:
return this.plain(part.text); return this.plain(part.text);
@ -508,19 +528,19 @@ export class PartCreator {
} }
} }
plain(text: string) { public plain(text: string): PlainPart {
return new PlainPart(text); return new PlainPart(text);
} }
newline() { public newline(): NewlinePart {
return new NewlinePart("\n"); return new NewlinePart("\n");
} }
pillCandidate(text: string) { public pillCandidate(text: string): PillCandidatePart {
return new PillCandidatePart(text, this.autoCompleteCreator); return new PillCandidatePart(text, this.autoCompleteCreator);
} }
roomPill(alias: string, roomId?: string) { public roomPill(alias: string, roomId?: string): RoomPillPart {
let room; let room;
if (roomId || alias[0] !== "#") { if (roomId || alias[0] !== "#") {
room = this.client.getRoom(roomId || alias); room = this.client.getRoom(roomId || alias);
@ -533,16 +553,20 @@ export class PartCreator {
return new RoomPillPart(alias, room ? room.name : alias, room); return new RoomPillPart(alias, room ? room.name : alias, room);
} }
atRoomPill(text: string) { public atRoomPill(text: string): AtRoomPillPart {
return new AtRoomPillPart(text, this.room); return new AtRoomPillPart(text, this.room);
} }
userPill(displayName: string, userId: string) { public userPill(displayName: string, userId: string): UserPillPart {
const member = this.room.getMember(userId); const member = this.room.getMember(userId);
return new UserPillPart(userId, displayName, member); return new UserPillPart(userId, displayName, member);
} }
createMentionParts(insertTrailingCharacter: boolean, displayName: string, userId: string) { public createMentionParts(
insertTrailingCharacter: boolean,
displayName: string,
userId: string,
): [UserPillPart, PlainPart] {
const pill = this.userPill(displayName, userId); const pill = this.userPill(displayName, userId);
const postfix = this.plain(insertTrailingCharacter ? ": " : " "); const postfix = this.plain(insertTrailingCharacter ? ": " : " ");
return [pill, postfix]; return [pill, postfix];
@ -567,7 +591,7 @@ export class CommandPartCreator extends PartCreator {
} }
public deserializePart(part: SerializedPart): Part { public deserializePart(part: SerializedPart): Part {
if (part.type === "command") { if (part.type === Type.Command) {
return this.command(part.text); return this.command(part.text);
} else { } else {
return super.deserializePart(part); return super.deserializePart(part);

View file

@ -30,7 +30,7 @@ export default class DocumentPosition implements IPosition {
constructor(public readonly index: number, public readonly offset: number) { constructor(public readonly index: number, public readonly offset: number) {
} }
compare(otherPos: DocumentPosition) { public compare(otherPos: DocumentPosition): number {
if (this.index === otherPos.index) { if (this.index === otherPos.index) {
return this.offset - otherPos.offset; return this.offset - otherPos.offset;
} else { } else {
@ -38,7 +38,7 @@ export default class DocumentPosition implements IPosition {
} }
} }
iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback) { public iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback): void {
if (this.index === -1 || other.index === -1) { if (this.index === -1 || other.index === -1) {
return; return;
} }
@ -57,7 +57,7 @@ export default class DocumentPosition implements IPosition {
} }
} }
forwardsWhile(model: EditorModel, predicate: Predicate) { public forwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
if (this.index === -1) { if (this.index === -1) {
return this; return this;
} }
@ -82,7 +82,7 @@ export default class DocumentPosition implements IPosition {
} }
} }
backwardsWhile(model: EditorModel, predicate: Predicate) { public backwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
if (this.index === -1) { if (this.index === -1) {
return this; return this;
} }
@ -107,7 +107,7 @@ export default class DocumentPosition implements IPosition {
} }
} }
asOffset(model: EditorModel) { public asOffset(model: EditorModel): DocumentOffset {
if (this.index === -1) { if (this.index === -1) {
return new DocumentOffset(0, true); return new DocumentOffset(0, true);
} }
@ -121,7 +121,7 @@ export default class DocumentPosition implements IPosition {
return new DocumentOffset(offset, atEnd); return new DocumentOffset(offset, atEnd);
} }
isAtEnd(model: EditorModel) { public isAtEnd(model: EditorModel): boolean {
if (model.parts.length === 0) { if (model.parts.length === 0) {
return true; return true;
} }
@ -130,7 +130,7 @@ export default class DocumentPosition implements IPosition {
return this.index === lastPartIdx && this.offset === lastPart.text.length; return this.index === lastPartIdx && this.offset === lastPart.text.length;
} }
isAtStart() { public isAtStart(): boolean {
return this.index === 0 && this.offset === 0; return this.index === 0 && this.offset === 0;
} }
} }

View file

@ -32,23 +32,23 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA; this._end = bIsLarger ? positionB : positionA;
} }
moveStart(delta: number) { public moveStart(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => { this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1; delta -= 1;
return delta >= 0; return delta >= 0;
}); });
} }
trim() { public trim(): void {
this._start = this._start.forwardsWhile(this.model, whitespacePredicate); this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate); this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
} }
expandBackwardsWhile(predicate: Predicate) { public expandBackwardsWhile(predicate: Predicate): void {
this._start = this._start.backwardsWhile(this.model, predicate); this._start = this._start.backwardsWhile(this.model, predicate);
} }
get text() { public get text(): string {
let text = ""; let text = "";
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => { this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const t = part.text.substring(startIdx, endIdx); const t = part.text.substring(startIdx, endIdx);
@ -63,7 +63,7 @@ export default class Range {
* @param {Part[]} parts the parts to replace the range with * @param {Part[]} parts the parts to replace the range with
* @return {Number} the net amount of characters added, can be negative. * @return {Number} the net amount of characters added, can be negative.
*/ */
replace(parts: Part[]) { public replace(parts: Part[]): number {
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0); const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
let oldLength = 0; let oldLength = 0;
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => { this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
@ -77,8 +77,8 @@ export default class Range {
* Returns a copy of the (partial) parts within the range. * Returns a copy of the (partial) parts within the range.
* For partial parts, only the text is adjusted to the part that intersects with the range. * For partial parts, only the text is adjusted to the part that intersects with the range.
*/ */
get parts() { public get parts(): Part[] {
const parts = []; const parts: Part[] = [];
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => { this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const serializedPart = part.serialize(); const serializedPart = part.serialize();
serializedPart.text = part.text.substring(startIdx, endIdx); serializedPart.text = part.text.substring(startIdx, endIdx);
@ -88,7 +88,7 @@ export default class Range {
return parts; return parts;
} }
get length() { public get length(): number {
let len = 0; let len = 0;
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => { this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
len += endIdx - startIdx; len += endIdx - startIdx;
@ -96,11 +96,11 @@ export default class Range {
return len; return len;
} }
get start() { public get start(): DocumentPosition {
return this._start; return this._start;
} }
get end() { public get end(): DocumentPosition {
return this._end; return this._end;
} }
} }

View file

@ -15,19 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Part } from "./parts"; import { Part, Type } from "./parts";
import EditorModel from "./model"; import EditorModel from "./model";
export function needsCaretNodeBefore(part: Part, prevPart: Part) { export function needsCaretNodeBefore(part: Part, prevPart: Part): boolean {
const isFirst = !prevPart || prevPart.type === "newline"; const isFirst = !prevPart || prevPart.type === Type.Newline;
return !part.canEdit && (isFirst || !prevPart.canEdit); return !part.canEdit && (isFirst || !prevPart.canEdit);
} }
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) { export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean): boolean {
return !part.canEdit && isLastOfLine; return !part.canEdit && isLastOfLine;
} }
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) { function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement): void {
const next = node.nextSibling; const next = node.nextSibling;
if (next) { if (next) {
node.parentElement.insertBefore(nodeToInsert, next); node.parentElement.insertBefore(nodeToInsert, next);
@ -44,25 +44,25 @@ export const CARET_NODE_CHAR = "\ufeff";
// a caret node is a node that allows the caret to be placed // a caret node is a node that allows the caret to be placed
// where otherwise it wouldn't be possible // where otherwise it wouldn't be possible
// (e.g. next to a pill span without adjacent text node) // (e.g. next to a pill span without adjacent text node)
function createCaretNode() { function createCaretNode(): HTMLElement {
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "caretNode"; span.className = "caretNode";
span.appendChild(document.createTextNode(CARET_NODE_CHAR)); span.appendChild(document.createTextNode(CARET_NODE_CHAR));
return span; return span;
} }
function updateCaretNode(node: HTMLElement) { function updateCaretNode(node: HTMLElement): void {
// ensure the caret node contains only a zero-width space // ensure the caret node contains only a zero-width space
if (node.textContent !== CARET_NODE_CHAR) { if (node.textContent !== CARET_NODE_CHAR) {
node.textContent = CARET_NODE_CHAR; node.textContent = CARET_NODE_CHAR;
} }
} }
export function isCaretNode(node: HTMLElement) { export function isCaretNode(node: HTMLElement): boolean {
return node && node.tagName === "SPAN" && node.className === "caretNode"; return node && node.tagName === "SPAN" && node.className === "caretNode";
} }
function removeNextSiblings(node: ChildNode) { function removeNextSiblings(node: ChildNode): void {
if (!node) { if (!node) {
return; return;
} }
@ -74,7 +74,7 @@ function removeNextSiblings(node: ChildNode) {
} }
} }
function removeChildren(parent: HTMLElement) { function removeChildren(parent: HTMLElement): void {
const firstChild = parent.firstChild; const firstChild = parent.firstChild;
if (firstChild) { if (firstChild) {
removeNextSiblings(firstChild); removeNextSiblings(firstChild);
@ -82,7 +82,7 @@ function removeChildren(parent: HTMLElement) {
} }
} }
function reconcileLine(lineContainer: ChildNode, parts: Part[]) { function reconcileLine(lineContainer: ChildNode, parts: Part[]): void {
let currentNode; let currentNode;
let prevPart; let prevPart;
const lastPart = parts[parts.length - 1]; const lastPart = parts[parts.length - 1];
@ -131,13 +131,13 @@ function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
removeNextSiblings(currentNode); removeNextSiblings(currentNode);
} }
function reconcileEmptyLine(lineContainer) { function reconcileEmptyLine(lineContainer: HTMLElement): void {
// empty div needs to have a BR in it to give it height // empty div needs to have a BR in it to give it height
let foundBR = false; let foundBR = false;
let partNode = lineContainer.firstChild; let partNode = lineContainer.firstChild;
while (partNode) { while (partNode) {
const nextNode = partNode.nextSibling; const nextNode = partNode.nextSibling;
if (!foundBR && partNode.tagName === "BR") { if (!foundBR && (partNode as HTMLElement).tagName === "BR") {
foundBR = true; foundBR = true;
} else { } else {
partNode.remove(); partNode.remove();
@ -149,9 +149,9 @@ function reconcileEmptyLine(lineContainer) {
} }
} }
export function renderModel(editor: HTMLDivElement, model: EditorModel) { export function renderModel(editor: HTMLDivElement, model: EditorModel): void {
const lines = model.parts.reduce((linesArr, part) => { const lines = model.parts.reduce((linesArr, part) => {
if (part.type === "newline") { if (part.type === Type.Newline) {
linesArr.push([]); linesArr.push([]);
} else { } else {
const lastLine = linesArr[linesArr.length - 1]; const lastLine = linesArr[linesArr.length - 1];
@ -175,7 +175,7 @@ export function renderModel(editor: HTMLDivElement, model: EditorModel) {
if (parts.length) { if (parts.length) {
reconcileLine(lineContainer, parts); reconcileLine(lineContainer, parts);
} else { } else {
reconcileEmptyLine(lineContainer); reconcileEmptyLine(lineContainer as HTMLElement);
} }
}); });
if (lines.length) { if (lines.length) {

View file

@ -22,30 +22,31 @@ import { AllHtmlEntities } from 'html-entities';
import SettingsStore from '../settings/SettingsStore'; import SettingsStore from '../settings/SettingsStore';
import SdkConfig from '../SdkConfig'; import SdkConfig from '../SdkConfig';
import cheerio from 'cheerio'; import cheerio from 'cheerio';
import { Type } from './parts';
export function mdSerialize(model: EditorModel) { export function mdSerialize(model: EditorModel): string {
return model.parts.reduce((html, part) => { return model.parts.reduce((html, part) => {
switch (part.type) { switch (part.type) {
case "newline": case Type.Newline:
return html + "\n"; return html + "\n";
case "plain": case Type.Plain:
case "command": case Type.Command:
case "pill-candidate": case Type.PillCandidate:
case "at-room-pill": case Type.AtRoomPill:
return html + part.text; return html + part.text;
case "room-pill": case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients // Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660 // See https://github.com/vector-im/element-web/issues/16660
return html + return html +
`[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; `[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
case "user-pill": case Type.UserPill:
return html + return html +
`[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
} }
}, ""); }, "");
} }
export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}) { export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}): string {
let md = mdSerialize(model); let md = mdSerialize(model);
// copy of raw input to remove unwanted math later // copy of raw input to remove unwanted math later
const orig = md; const orig = md;
@ -156,31 +157,31 @@ export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false }
} }
} }
export function textSerialize(model: EditorModel) { export function textSerialize(model: EditorModel): string {
return model.parts.reduce((text, part) => { return model.parts.reduce((text, part) => {
switch (part.type) { switch (part.type) {
case "newline": case Type.Newline:
return text + "\n"; return text + "\n";
case "plain": case Type.Plain:
case "command": case Type.Command:
case "pill-candidate": case Type.PillCandidate:
case "at-room-pill": case Type.AtRoomPill:
return text + part.text; return text + part.text;
case "room-pill": case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients // Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660 // See https://github.com/vector-im/element-web/issues/16660
return text + `${part.resourceId}`; return text + `${part.resourceId}`;
case "user-pill": case Type.UserPill:
return text + `${part.text}`; return text + `${part.text}`;
} }
}, ""); }, "");
} }
export function containsEmote(model: EditorModel) { export function containsEmote(model: EditorModel): boolean {
return startsWith(model, "/me ", false); return startsWith(model, "/me ", false);
} }
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true) { export function startsWith(model: EditorModel, prefix: string, caseSensitive = true): boolean {
const firstPart = model.parts[0]; const firstPart = model.parts[0];
// part type will be "plain" while editing, // part type will be "plain" while editing,
// and "command" while composing a message. // and "command" while composing a message.
@ -190,26 +191,26 @@ export function startsWith(model: EditorModel, prefix: string, caseSensitive = t
text = text.toLowerCase(); text = text.toLowerCase();
} }
return firstPart && (firstPart.type === "plain" || firstPart.type === "command") && text.startsWith(prefix); return firstPart && (firstPart.type === Type.Plain || firstPart.type === Type.Command) && text.startsWith(prefix);
} }
export function stripEmoteCommand(model: EditorModel) { export function stripEmoteCommand(model: EditorModel): EditorModel {
// trim "/me " // trim "/me "
return stripPrefix(model, "/me "); return stripPrefix(model, "/me ");
} }
export function stripPrefix(model: EditorModel, prefix: string) { export function stripPrefix(model: EditorModel, prefix: string): EditorModel {
model = model.clone(); model = model.clone();
model.removeText({ index: 0, offset: 0 }, prefix.length); model.removeText({ index: 0, offset: 0 }, prefix.length);
return model; return model;
} }
export function unescapeMessage(model: EditorModel) { export function unescapeMessage(model: EditorModel): EditorModel {
const { parts } = model; const { parts } = model;
if (parts.length) { if (parts.length) {
const firstPart = parts[0]; const firstPart = parts[0];
// only unescape \/ to / at start of editor // only unescape \/ to / at start of editor
if (firstPart.type === "plain" && firstPart.text.startsWith("\\/")) { if (firstPart.type === Type.Plain && firstPart.text.startsWith("\\/")) {
model = model.clone(); model = model.clone();
model.removeText({ index: 0, offset: 0 }, 1); model.removeText({ index: 0, offset: 0 }, 1);
} }