From ed0d9973b731891590136ba8ea54f9914b5eb306 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 20 Jul 2020 16:33:53 +0100 Subject: [PATCH] Switch to a discriminated unions Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/autocomplete.ts | 8 +- src/editor/caret.ts | 6 +- src/editor/history.ts | 5 +- src/editor/model.ts | 16 ++-- src/editor/operations.ts | 6 +- src/editor/parts.ts | 145 ++++++++++++++++++++++++++----------- src/editor/position.ts | 6 +- src/editor/render.ts | 9 +-- src/editor/serialize.ts | 3 +- 9 files changed, 130 insertions(+), 74 deletions(-) diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index 5832557ae9..0834c0429a 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -17,13 +17,13 @@ limitations under the License. import {KeyboardEvent} from "react"; -import {BasePart, CommandPartCreator, PartCreator} from "./parts"; +import {Part, CommandPartCreator, PartCreator} from "./parts"; import DocumentPosition from "./position"; import {ICompletion} from "../autocomplete/Autocompleter"; import Autocomplete from "../components/views/rooms/Autocomplete"; export interface ICallback { - replaceParts?: BasePart[]; + replaceParts?: Part[]; close?: boolean; } @@ -32,7 +32,7 @@ export type GetAutocompleterComponent = () => Autocomplete; export type UpdateQuery = (test: string) => Promise; export default class AutocompleteWrapperModel { - private queryPart: BasePart; + private queryPart: Part; private partIndex: number; constructor( @@ -89,7 +89,7 @@ export default class AutocompleteWrapperModel { this.getAutocompleterComponent().moveSelection(+1); } - onPartUpdate(part: BasePart, pos: DocumentPosition) { + onPartUpdate(part: Part, pos: DocumentPosition) { // 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; diff --git a/src/editor/caret.ts b/src/editor/caret.ts index cfbc701183..4864f9aa95 100644 --- a/src/editor/caret.ts +++ b/src/editor/caret.ts @@ -19,7 +19,7 @@ import {needsCaretNodeBefore, needsCaretNodeAfter} from "./render"; import Range from "./range"; import EditorModel from "./model"; import DocumentPosition, {IPosition} from "./position"; -import {BasePart} from "./parts"; +import {Part} from "./parts"; export type Caret = Range | DocumentPosition; @@ -104,7 +104,7 @@ export function getLineAndNodePosition(model: EditorModel, caretPosition: IPosit return {lineIndex, nodeIndex, offset}; } -function findNodeInLineForPart(parts: BasePart[], partIndex: number) { +function findNodeInLineForPart(parts: Part[], partIndex: number) { let lineIndex = 0; let nodeIndex = -1; @@ -140,7 +140,7 @@ function findNodeInLineForPart(parts: BasePart[], partIndex: number) { return {lineIndex, nodeIndex}; } -function moveOutOfUneditablePart(parts: BasePart[], partIndex: number, nodeIndex: number, offset: number) { +function moveOutOfUneditablePart(parts: Part[], partIndex: number, nodeIndex: number, offset: number) { // move caret before or after uneditable part const part = parts[partIndex]; if (part && !part.canEdit) { diff --git a/src/editor/history.ts b/src/editor/history.ts index f0dc3c251b..d2c96a3755 100644 --- a/src/editor/history.ts +++ b/src/editor/history.ts @@ -16,12 +16,11 @@ limitations under the License. import EditorModel from "./model"; import {IDiff} from "./diff"; -import {ISerializedPart} from "./parts"; -import Range from "./range"; +import {SerializedPart} from "./parts"; import {Caret} from "./caret"; interface IHistory { - parts: ISerializedPart[]; + parts: SerializedPart[]; caret: Caret; } diff --git a/src/editor/model.ts b/src/editor/model.ts index 37a1ada543..7cc1985346 100644 --- a/src/editor/model.ts +++ b/src/editor/model.ts @@ -18,7 +18,7 @@ limitations under the License. import {diffAtCaret, diffDeletion, IDiff} from "./diff"; import DocumentPosition, {IPosition} from "./position"; import Range from "./range"; -import {BasePart, ISerializedPart, PartCreator} from "./parts"; +import {SerializedPart, Part, PartCreator} from "./parts"; import AutocompleteWrapperModel, {ICallback} from "./autocomplete"; import DocumentOffset from "./offset"; import {Caret} from "./caret"; @@ -49,7 +49,7 @@ type UpdateCallback = (caret: Caret, inputType?: string, diff?: IDiff) => void; type ManualTransformCallback = () => Caret; export default class EditorModel { - private _parts: BasePart[]; + private _parts: Part[]; private readonly _partCreator: PartCreator; private activePartIdx: number = null; private _autoComplete: AutocompleteWrapperModel = null; @@ -57,7 +57,7 @@ export default class EditorModel { private autoCompletePartCount = 0; private transformCallback: TransformCallback = null; - constructor(parts: BasePart[], partCreator: PartCreator, private updateCallback: UpdateCallback = null) { + constructor(parts: Part[], partCreator: PartCreator, private updateCallback: UpdateCallback = null) { this._parts = parts; this._partCreator = partCreator; this.transformCallback = null; @@ -94,7 +94,7 @@ export default class EditorModel { return new EditorModel(this._parts, this._partCreator, this.updateCallback); } - private insertPart(index: number, part: BasePart) { + private insertPart(index: number, part: Part) { this._parts.splice(index, 0, part); if (this.activePartIdx >= index) { ++this.activePartIdx; @@ -118,7 +118,7 @@ export default class EditorModel { } } - private replacePart(index: number, part: BasePart) { + private replacePart(index: number, part: Part) { this._parts.splice(index, 1, part); } @@ -158,7 +158,7 @@ export default class EditorModel { } } - reset(serializedParts: ISerializedPart[], caret: Caret, inputType: string) { + reset(serializedParts: SerializedPart[], caret: Caret, inputType: string) { this._parts = serializedParts.map(p => this._partCreator.deserializePart(p)); if (!caret) { caret = this.getPositionAtEnd(); @@ -180,7 +180,7 @@ export default class EditorModel { * @param {DocumentPosition} position the position to start inserting at * @return {Number} the amount of characters added */ - insert(parts: BasePart[], position: IPosition) { + insert(parts: Part[], position: IPosition) { const insertIndex = this.splitAt(position); let newTextLength = 0; for (let i = 0; i < parts.length; ++i) { @@ -420,7 +420,7 @@ export default class EditorModel { return new Range(this, positionA, positionB); } - replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: BasePart[]) { + replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]) { // convert end position to offset, so it is independent of how the document is split into parts // which we'll change when splitting up at the start position const endOffset = endPosition.asOffset(this); diff --git a/src/editor/operations.ts b/src/editor/operations.ts index ee3aa04671..3f07139de3 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -15,13 +15,13 @@ limitations under the License. */ import Range from "./range"; -import {BasePart} from "./parts"; +import {Part} from "./parts"; /** * Some common queries and transformations on the editor model */ -export function replaceRangeAndExpandSelection(range: Range, newParts: BasePart[]) { +export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) { const {model} = range; model.transform(() => { const oldLen = range.length; @@ -32,7 +32,7 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: BasePart[ }); } -export function replaceRangeAndMoveCaret(range: Range, newParts: BasePart[]) { +export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) { const {model} = range; model.transform(() => { const oldLen = range.length; diff --git a/src/editor/parts.ts b/src/editor/parts.ts index f90308a202..18fedffe4b 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -19,15 +19,66 @@ import {MatrixClient} from "matrix-js-sdk/src/client"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import {Room} from "matrix-js-sdk/src/models/room"; -import AutocompleteWrapperModel, {GetAutocompleterComponent, UpdateCallback, UpdateQuery} from "./autocomplete"; +import AutocompleteWrapperModel, { + GetAutocompleterComponent, + UpdateCallback, + UpdateQuery +} from "./autocomplete"; import * as Avatar from "../Avatar"; -export interface ISerializedPart { - type: string; +interface ISerializedPart { + type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate; text: string; } -export abstract class BasePart { +interface ISerializedPillPart { + type: Type.AtRoomPill | Type.RoomPill | Type.UserPill; + text: string; + resourceId: string; +} + +export type SerializedPart = ISerializedPart | ISerializedPillPart; + +enum Type { + Plain = "plain", + Newline = "newline", + Command = "command", + UserPill = "user-pill", + RoomPill = "room-pill", + AtRoomPill = "at-room-pill", + PillCandidate = "pill-candidate", +} + +interface IBasePart { + text: string; + type: Type.Plain | Type.Newline; + canEdit: boolean; + + createAutoComplete(updateCallback: UpdateCallback): void; + + serialize(): SerializedPart; + remove(offset: number, len: number): string; + split(offset: number): IBasePart; + validateAndInsert(offset: number, str: string, inputType: string): boolean; + appendUntilRejected(str: string, inputType: string): string; + updateDOMNode(node: Node); + canUpdateDOMNode(node: Node); + toDOMNode(): Node; +} + +interface IPillCandidatePart extends Omit { + type: Type.PillCandidate | Type.Command; + createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel; +} + +interface IPillPart extends Omit { + type: Type.AtRoomPill | Type.RoomPill | Type.UserPill; + resourceId: string; +} + +export type Part = IBasePart | IPillCandidatePart | IPillPart; + +abstract class BasePart { protected _text: string; constructor(text = "") { @@ -42,7 +93,7 @@ export abstract class BasePart { return true; } - merge(part: BasePart) { + merge(part: Part) { return false; } @@ -94,7 +145,7 @@ export abstract class BasePart { return true; } - createAutoComplete(updateCallback): AutocompleteWrapperModel | void {} + createAutoComplete(updateCallback: UpdateCallback): void {} trim(len: number) { const remaining = this._text.substr(len); @@ -106,7 +157,7 @@ export abstract class BasePart { return this._text; } - abstract get type(): string; + abstract get type(): Type; get canEdit() { return true; @@ -116,8 +167,11 @@ export abstract class BasePart { return `${this.type}(${this.text})`; } - serialize(): ISerializedPart { - return {type: this.type, text: this.text}; + serialize(): SerializedPart { + return { + type: this.type as ISerializedPart["type"], + text: this.text, + }; } abstract updateDOMNode(node: Node); @@ -125,8 +179,7 @@ export abstract class BasePart { abstract toDOMNode(): Node; } -// exported for unit tests, should otherwise only be used through PartCreator -export class PlainPart extends BasePart { +abstract class PlainBasePart extends BasePart { acceptsInsertion(chr: string, offset: number, inputType: string) { if (chr === "\n") { return false; @@ -150,10 +203,6 @@ export class PlainPart extends BasePart { return false; } - get type() { - return "plain"; - } - updateDOMNode(node: Node) { if (node.textContent !== this.text) { node.textContent = this.text; @@ -165,7 +214,14 @@ export class PlainPart extends BasePart { } } -export abstract class PillPart extends BasePart { +// exported for unit tests, should otherwise only be used through PartCreator +export class PlainPart extends PlainBasePart implements IBasePart { + get type(): IBasePart["type"] { + return Type.Plain; + } +} + +abstract class PillPart extends BasePart implements IPillPart { constructor(public resourceId: string, label) { super(label); } @@ -223,12 +279,14 @@ export abstract class PillPart extends BasePart { return false; } + abstract get type(): IPillPart["type"]; + abstract get className(): string; abstract setAvatar(node: HTMLElement): void; } -class NewlinePart extends BasePart { +class NewlinePart extends BasePart implements IBasePart { acceptsInsertion(chr: string, offset: number) { return offset === 0 && chr === "\n"; } @@ -251,8 +309,8 @@ class NewlinePart extends BasePart { return node.tagName === "BR"; } - get type() { - return "newline"; + get type(): IBasePart["type"] { + return Type.Newline; } // this makes the cursor skip this part when it is inserted @@ -283,8 +341,8 @@ class RoomPillPart extends PillPart { this._setAvatarVars(node, avatarUrl, initialLetter); } - get type() { - return "room-pill"; + get type(): IPillPart["type"] { + return Type.RoomPill; } get className() { @@ -293,8 +351,8 @@ class RoomPillPart extends PillPart { } class AtRoomPillPart extends RoomPillPart { - get type() { - return "at-room-pill"; + get type(): IPillPart["type"] { + return Type.AtRoomPill; } } @@ -321,28 +379,29 @@ class UserPillPart extends PillPart { this._setAvatarVars(node, avatarUrl, initialLetter); } - get type() { - return "user-pill"; + get type(): IPillPart["type"] { + return Type.UserPill; } get className() { return "mx_UserPill mx_Pill"; } - serialize() { + serialize(): ISerializedPillPart { return { - ...super.serialize(), + type: this.type, + text: this.text, resourceId: this.resourceId, }; } } -class PillCandidatePart extends PlainPart { +class PillCandidatePart extends PlainBasePart implements IPillCandidatePart { constructor(text: string, private autoCompleteCreator: IAutocompleteCreator) { super(text); } - createAutoComplete(updateCallback): AutocompleteWrapperModel { + createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel { return this.autoCompleteCreator.create(updateCallback); } @@ -362,8 +421,8 @@ class PillCandidatePart extends PlainPart { return true; } - get type() { - return "pill-candidate"; + get type(): IPillCandidatePart["type"] { + return Type.PillCandidate; } } @@ -399,7 +458,7 @@ export class PartCreator { this.autoCompleteCreator.create = autoCompleteCreator(this); } - createPartForInput(input: string, partIndex: number, inputType?: string): BasePart { + createPartForInput(input: string, partIndex: number, inputType?: string): Part { switch (input[0]) { case "#": case "@": @@ -416,20 +475,20 @@ export class PartCreator { return this.plain(text); } - deserializePart(part: ISerializedPart) { + deserializePart(part: SerializedPart): Part { switch (part.type) { - case "plain": + case Type.Plain: return this.plain(part.text); - case "newline": + case Type.Newline: return this.newline(); - case "at-room-pill": + case Type.AtRoomPill: return this.atRoomPill(part.text); - case "pill-candidate": + case Type.PillCandidate: return this.pillCandidate(part.text); - case "room-pill": + case Type.RoomPill: return this.roomPill(part.text); - case "user-pill": - return this.userPill(part.text, (part as PillPart).resourceId); + case Type.UserPill: + return this.userPill(part.text, part.resourceId); } } @@ -491,7 +550,7 @@ export class CommandPartCreator extends PartCreator { return new CommandPart(text, this.autoCompleteCreator); } - deserializePart(part: BasePart) { + deserializePart(part: Part): Part { if (part.type === "command") { return this.command(part.text); } else { @@ -501,7 +560,7 @@ export class CommandPartCreator extends PartCreator { } class CommandPart extends PillCandidatePart { - get type() { - return "command"; + get type(): IPillCandidatePart["type"] { + return Type.Command; } } diff --git a/src/editor/position.ts b/src/editor/position.ts index 9c12fff778..821ad331c4 100644 --- a/src/editor/position.ts +++ b/src/editor/position.ts @@ -16,15 +16,15 @@ limitations under the License. import DocumentOffset from "./offset"; import EditorModel from "./model"; -import {BasePart} from "./parts"; +import {Part} from "./parts"; export interface IPosition { index: number; offset: number; } -type Callback = (part: BasePart, startIdx: number, endIdx: number) => void; -type Predicate = (index: number, offset: number, part: BasePart) => boolean; +type Callback = (part: Part, startIdx: number, endIdx: number) => void; +type Predicate = (index: number, offset: number, part: Part) => boolean; export default class DocumentPosition implements IPosition { constructor(public readonly index: number, public readonly offset: number) { diff --git a/src/editor/render.ts b/src/editor/render.ts index a60fb19730..e0f7c07975 100644 --- a/src/editor/render.ts +++ b/src/editor/render.ts @@ -15,16 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BasePart} from "./parts"; +import {Part} from "./parts"; import EditorModel from "./model"; -import {instanceOf} from "prop-types"; -export function needsCaretNodeBefore(part: BasePart, prevPart: BasePart) { +export function needsCaretNodeBefore(part: Part, prevPart: Part) { const isFirst = !prevPart || prevPart.type === "newline"; return !part.canEdit && (isFirst || !prevPart.canEdit); } -export function needsCaretNodeAfter(part: BasePart, isLastOfLine: boolean) { +export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) { return !part.canEdit && isLastOfLine; } @@ -83,7 +82,7 @@ function removeChildren(parent: HTMLElement) { } } -function reconcileLine(lineContainer: ChildNode, parts: BasePart[]) { +function reconcileLine(lineContainer: ChildNode, parts: Part[]) { let currentNode; let prevPart; const lastPart = parts[parts.length - 1]; diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index 8ee726e8a1..7e8f4a3bfc 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -18,7 +18,6 @@ limitations under the License. import Markdown from '../Markdown'; import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; import EditorModel from "./model"; -import {PillPart} from "./parts"; export function mdSerialize(model: EditorModel) { return model.parts.reduce((html, part) => { @@ -32,7 +31,7 @@ export function mdSerialize(model: EditorModel) { return html + part.text; case "room-pill": case "user-pill": - return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink((part as PillPart).resourceId)})`; + return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; } }, ""); }