Fix strict `strictNullChecks` to `src/editor/*` (#10428

* Fix strict `strictNullChecks` to `src/editor/*`

* Fix autoComplete creation

* Fix dom regression

* Remove changes
pull/28788/head^2
Florian Duros 2023-03-23 14:35:55 +01:00 committed by GitHub
parent e19127f8ad
commit e4dfb21e56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 85 additions and 59 deletions

View File

@ -573,24 +573,28 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.onFormatAction(Formatting.InsertLink);
handled = true;
break;
case KeyBindingAction.EditRedo:
if (this.historyManager.canRedo()) {
const { parts, caret } = this.historyManager.redo();
case KeyBindingAction.EditRedo: {
const history = this.historyManager.redo();
if (history) {
const { parts, caret } = history;
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyRedo");
}
handled = true;
break;
case KeyBindingAction.EditUndo:
if (this.historyManager.canUndo()) {
const { parts, caret } = this.historyManager.undo(this.props.model);
}
case KeyBindingAction.EditUndo: {
const history = this.historyManager.undo(this.props.model);
if (history) {
const { parts, caret } = history;
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyUndo");
}
handled = true;
break;
}
case KeyBindingAction.NewLine:
this.insertText("\n");
handled = true;

View File

@ -99,12 +99,15 @@ export default class AutocompleteWrapperModel {
const text = completion.completion;
switch (completion.type) {
case "room":
return [this.partCreator.roomPill(text, completionId), this.partCreator.plain(completion.suffix)];
return [this.partCreator.roomPill(text, completionId), this.partCreator.plain(completion.suffix || "")];
case "at-room":
return [this.partCreator.atRoomPill(completionId), this.partCreator.plain(completion.suffix)];
return [
this.partCreator.atRoomPill(completionId || ""),
this.partCreator.plain(completion.suffix || ""),
];
case "user":
// Insert suffix only if the pill is the part with index 0 - we are at the start of the composer
return this.partCreator.createMentionParts(this.partIndex === 0, text, completionId);
return this.partCreator.createMentionParts(this.partIndex === 0, text, completionId || "");
case "command":
// command needs special handling for auto complete, but also renders as plain texts
return [(this.partCreator as CommandPartCreator).command(text)];

View File

@ -137,5 +137,5 @@ export async function shouldSendAnyway(commandText: string): Promise<boolean> {
button: _t("Send as message"),
});
const [sendAnyway] = await finished;
return sendAnyway;
return sendAnyway || false;
}

View File

@ -78,7 +78,7 @@ function parseLink(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
switch (resourceId?.[0]) {
case "@":
return [pc.userPill(n.textContent, resourceId)];
return [pc.userPill(n.textContent || "", resourceId)];
case "#":
return [pc.roomPill(resourceId)];
}
@ -97,6 +97,8 @@ function parseImage(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
}
function parseCodeBlock(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
if (!n.textContent) return [];
let language = "";
if (n.firstChild?.nodeName === "CODE") {
for (const className of (n.firstChild as HTMLElement).classList) {
@ -170,7 +172,7 @@ function parseNode(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (
switch (n.nodeType) {
case Node.TEXT_NODE:
return parseAtRoomMentions(n.nodeValue, pc, opts);
return parseAtRoomMentions(n.nodeValue || "", pc, opts);
case Node.ELEMENT_NODE:
switch (n.nodeName) {
case "H1":
@ -204,7 +206,7 @@ function parseNode(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (
return parseCodeBlock(n, pc, opts);
case "CODE": {
// Escape backticks by using multiple backticks for the fence if necessary
const fence = "`".repeat(longestBacktickSequence(n.textContent) + 1);
const fence = "`".repeat(longestBacktickSequence(n.textContent || "") + 1);
return pc.plainWithEmoji(`${fence}${n.textContent}${fence}`);
}
case "BLOCKQUOTE": {

View File

@ -34,11 +34,11 @@ export function walkDOMDepthFirst(rootNode: Node, enterNodeCallback: Predicate,
} else {
while (node && !node.nextSibling && node !== rootNode) {
node = node.parentElement;
if (node !== rootNode) {
if (node && node !== rootNode) {
leaveNodeCallback(node);
}
}
if (node !== rootNode) {
if (node && node !== rootNode) {
node = node.nextSibling;
}
}
@ -57,10 +57,10 @@ export function getCaretOffsetAndText(
}
function tryReduceSelectionToTextNode(
selectionNode: Node,
selectionNode: Node | null,
selectionOffset: number,
): {
node: Node;
node: Node | null;
characterOffset: number;
} {
// if selectionNode is an element, the selected location comes after the selectionOffset-th child node,
@ -73,8 +73,8 @@ function tryReduceSelectionToTextNode(
if (childNodeCount) {
if (selectionOffset >= childNodeCount) {
selectionNode = selectionNode.lastChild;
if (selectionNode.nodeType === Node.TEXT_NODE) {
selectionOffset = selectionNode.textContent.length;
if (selectionNode?.nodeType === Node.TEXT_NODE) {
selectionOffset = selectionNode.textContent?.length || 0;
} else {
// this will select the last child node in the next iteration
selectionOffset = Number.MAX_SAFE_INTEGER;
@ -101,7 +101,7 @@ function tryReduceSelectionToTextNode(
function getSelectionOffsetAndText(
editor: HTMLDivElement,
selectionNode: Node,
selectionNode: Node | null,
selectionOffset: number,
): {
offset: DocumentOffset;
@ -115,14 +115,15 @@ function getSelectionOffsetAndText(
// gets the caret position details, ignoring and adjusting to
// the ZWS if you're typing in a caret node
function getCaret(node: Node, offsetToNode: number, offsetWithinNode: number): DocumentOffset {
function getCaret(node: Node | null, offsetToNode: number, offsetWithinNode: number): DocumentOffset {
// if no node is selected, return an offset at the start
if (!node) {
return new DocumentOffset(0, false);
}
let atNodeEnd = offsetWithinNode === node.textContent.length;
let atNodeEnd = offsetWithinNode === node.textContent?.length;
if (node.nodeType === Node.TEXT_NODE && isCaretNode(node.parentElement)) {
const zwsIdx = node.nodeValue.indexOf(CARET_NODE_CHAR);
const nodeValue = node.nodeValue || "";
const zwsIdx = nodeValue.indexOf(CARET_NODE_CHAR);
if (zwsIdx !== -1 && zwsIdx < offsetWithinNode) {
offsetWithinNode -= 1;
}
@ -138,7 +139,10 @@ function getCaret(node: Node, offsetToNode: number, offsetWithinNode: number): D
// gets the text of the editor as a string,
// and the offset in characters where the selectionNode starts in that string
// all ZWS from caret nodes are filtered out
function getTextAndOffsetToNode(editor: HTMLDivElement, selectionNode: Node): { offsetToNode: number; text: string } {
function getTextAndOffsetToNode(
editor: HTMLDivElement,
selectionNode: Node | null,
): { offsetToNode: number; text: string } {
let offsetToNode = 0;
let foundNode = false;
let text = "";

View File

@ -19,7 +19,7 @@ import { IDiff } from "./diff";
import { SerializedPart } from "./parts";
import { Caret } from "./caret";
interface IHistory {
export interface IHistory {
parts: SerializedPart[];
caret: Caret;
}
@ -121,7 +121,7 @@ export default class HistoryManager {
}
public ensureLastChangesPushed(model: EditorModel): void {
if (this.changedSinceLastPush) {
if (this.changedSinceLastPush && this.lastCaret) {
this.pushState(model, this.lastCaret);
}
}
@ -135,7 +135,7 @@ export default class HistoryManager {
}
// returns state that should be applied to model
public undo(model: EditorModel): IHistory {
public undo(model: EditorModel): IHistory | void {
if (this.canUndo()) {
this.ensureLastChangesPushed(model);
this.currentIndex -= 1;
@ -144,7 +144,7 @@ export default class HistoryManager {
}
// returns state that should be applied to model
public redo(): IHistory {
public redo(): IHistory | void {
if (this.canRedo()) {
this.changedSinceLastPush = false;
this.currentIndex += 1;

View File

@ -91,16 +91,18 @@ export default class EditorModel {
}
public clone(): EditorModel {
const clonedParts = this.parts.map((p) => this.partCreator.deserializePart(p.serialize()));
const clonedParts = this.parts
.map((p) => this.partCreator.deserializePart(p.serialize()))
.filter((p): p is Part => Boolean(p));
return new EditorModel(clonedParts, this._partCreator, this.updateCallback);
}
private insertPart(index: number, part: Part): void {
this._parts.splice(index, 0, part);
if (this.activePartIdx >= index) {
if (this.activePartIdx && this.activePartIdx >= index) {
++this.activePartIdx;
}
if (this.autoCompletePartIdx >= index) {
if (this.autoCompletePartIdx && this.autoCompletePartIdx >= index) {
++this.autoCompletePartIdx;
}
}
@ -109,12 +111,12 @@ export default class EditorModel {
this._parts.splice(index, 1);
if (index === this.activePartIdx) {
this.activePartIdx = null;
} else if (this.activePartIdx > index) {
} else if (this.activePartIdx && this.activePartIdx > index) {
--this.activePartIdx;
}
if (index === this.autoCompletePartIdx) {
this.autoCompletePartIdx = null;
} else if (this.autoCompletePartIdx > index) {
} else if (this.autoCompletePartIdx && this.autoCompletePartIdx > index) {
--this.autoCompletePartIdx;
}
}
@ -160,7 +162,9 @@ export default class EditorModel {
}
public reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string): void {
this._parts = serializedParts.map((p) => this._partCreator.deserializePart(p));
this._parts = serializedParts
.map((p) => this._partCreator.deserializePart(p))
.filter((p): p is Part => Boolean(p));
if (!caret) {
caret = this.getPositionAtEnd();
}
@ -194,7 +198,7 @@ export default class EditorModel {
public update(newValue: string, inputType: string, caret: DocumentOffset): Promise<void> {
const diff = this.diff(newValue, inputType, caret);
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
const position = this.positionForOffset(diff.at || 0, caret.atNodeEnd);
let removedOffsetDecrease = 0;
if (diff.removed) {
removedOffsetDecrease = this.removeText(position, diff.removed.length);
@ -204,7 +208,7 @@ export default class EditorModel {
addedLen = this.addText(position, diff.added, inputType);
}
this.mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
const caretOffset = (diff.at || 0) - removedOffsetDecrease + addedLen;
let newPosition = this.positionForOffset(caretOffset, true);
const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
const acPromise = this.setActivePart(newPosition, canOpenAutoComplete);
@ -254,10 +258,11 @@ export default class EditorModel {
private onAutoComplete = ({ replaceParts, close }: ICallback): void => {
let pos: DocumentPosition | undefined;
if (replaceParts) {
this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
const autoCompletePartIdx = this.autoCompletePartIdx || 0;
this._parts.splice(autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
this.autoCompletePartCount = replaceParts.length;
const lastPart = replaceParts[replaceParts.length - 1];
const lastPartIndex = this.autoCompletePartIdx + replaceParts.length - 1;
const lastPartIndex = autoCompletePartIdx + replaceParts.length - 1;
pos = new DocumentPosition(lastPartIndex, lastPart.text.length);
}
if (close) {
@ -360,10 +365,13 @@ export default class EditorModel {
const { offset } = pos;
let addLen = str.length;
const part = this._parts[index];
let it: string | undefined = str;
if (part) {
if (part.canEdit) {
if (part.validateAndInsert(offset, str, inputType)) {
str = null;
it = undefined;
} else {
const splitPart = part.split(offset);
index += 1;
@ -381,7 +389,6 @@ export default class EditorModel {
index = 0;
}
let it: string | undefined = str;
while (it) {
const newPart = this._partCreator.createPartForInput(it, index, inputType);
const oldStr = it;

View File

@ -241,7 +241,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix
const { partCreator } = model;
// compute paragraph [start, end] indexes
const paragraphIndexes = [];
const paragraphIndexes: [number, number][] = [];
let startIndex = 0;
// start at i=2 because we look at i and up to two parts behind to detect paragraph breaks at their end
@ -285,12 +285,18 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix
// remove prefix and suffix formatting string
const partWithoutPrefix = parts[base].serialize();
partWithoutPrefix.text = partWithoutPrefix.text.slice(prefix.length);
parts[base] = partCreator.deserializePart(partWithoutPrefix);
let deserializedPart = partCreator.deserializePart(partWithoutPrefix);
if (deserializedPart) {
parts[base] = deserializedPart;
}
const partWithoutSuffix = parts[index - 1].serialize();
const suffixPartText = partWithoutSuffix.text;
partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length);
parts[index - 1] = partCreator.deserializePart(partWithoutSuffix);
deserializedPart = partCreator.deserializePart(partWithoutSuffix);
if (deserializedPart) {
parts[index - 1] = deserializedPart;
}
} else {
parts.splice(index, 0, partCreator.plain(suffix)); // splice in the later one first to not change offset
parts.splice(base, 0, partCreator.plain(prefix));

View File

@ -76,7 +76,7 @@ interface IBasePart {
interface IPillCandidatePart extends Omit<IBasePart, "type" | "createAutoComplete"> {
type: Type.PillCandidate | Type.Command;
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel;
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel | undefined;
}
interface IPillPart extends Omit<IBasePart, "type" | "resourceId"> {
@ -272,7 +272,7 @@ export abstract class PillPart extends BasePart implements IPillPart {
const container = document.createElement("span");
container.setAttribute("spellcheck", "false");
container.setAttribute("contentEditable", "false");
container.onclick = this.onClick;
if (this.onClick) container.onclick = this.onClick;
container.className = this.className;
container.appendChild(document.createTextNode(this.text));
this.setAvatar(container);
@ -287,7 +287,7 @@ export abstract class PillPart extends BasePart implements IPillPart {
if (node.className !== this.className) {
node.className = this.className;
}
if (node.onclick !== this.onClick) {
if (this.onClick && node.onclick !== this.onClick) {
node.onclick = this.onClick;
}
this.setAvatar(node);
@ -496,8 +496,8 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
super(text);
}
public createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
return this.autoCompleteCreator.create(updateCallback);
public createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel | undefined {
return this.autoCompleteCreator.create?.(updateCallback);
}
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
@ -532,7 +532,7 @@ export function getAutoCompleteCreator(getAutocompleterComponent: GetAutocomplet
type AutoCompleteCreator = ReturnType<typeof getAutoCompleteCreator>;
interface IAutocompleteCreator {
create(updateCallback: UpdateCallback): AutocompleteWrapperModel;
create: ((updateCallback: UpdateCallback) => AutocompleteWrapperModel) | undefined;
}
export class PartCreator {
@ -587,9 +587,9 @@ export class PartCreator {
case Type.PillCandidate:
return this.pillCandidate(part.text);
case Type.RoomPill:
return this.roomPill(part.resourceId);
return part.resourceId ? this.roomPill(part.resourceId) : undefined;
case Type.UserPill:
return this.userPill(part.text, part.resourceId);
return part.resourceId ? this.userPill(part.text, part.resourceId) : undefined;
}
}

View File

@ -118,7 +118,7 @@ export default class Range {
const serializedPart = part.serialize();
serializedPart.text = part.text.substring(startIdx, endIdx);
const newPart = this.model.partCreator.deserializePart(serializedPart);
parts.push(newPart);
if (newPart) parts.push(newPart);
});
return parts;
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import HistoryManager, { MAX_STEP_LENGTH } from "../../src/editor/history";
import HistoryManager, { IHistory, MAX_STEP_LENGTH } from "../../src/editor/history";
import EditorModel from "../../src/editor/model";
import DocumentPosition from "../../src/editor/position";
@ -29,7 +29,7 @@ describe("editor/history", function () {
parts[0] = "hello world";
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
expect(history.canUndo()).toEqual(true);
const undoState = history.undo(model);
const undoState = history.undo(model) as IHistory;
expect(undoState.caret).toBe(caret1);
expect(undoState.parts).toEqual(["hello"]);
expect(history.canUndo()).toEqual(false);
@ -44,7 +44,7 @@ describe("editor/history", function () {
history.tryPush(model, caret2, "insertText", {});
history.undo(model);
expect(history.canRedo()).toEqual(true);
const redoState = history.redo();
const redoState = history.redo() as IHistory;
expect(redoState.caret).toBe(caret2);
expect(redoState.parts).toEqual(["hello world"]);
expect(history.canRedo()).toEqual(false);
@ -74,7 +74,7 @@ describe("editor/history", function () {
parts[0] = parts[0] + diff.added;
keystrokeCount += 1;
} while (!history.tryPush(model, new DocumentPosition(0, 0), "insertText", diff));
const undoState = history.undo(model);
const undoState = history.undo(model) as IHistory;
expect(undoState.caret).toBe(firstCaret);
expect(undoState.parts).toEqual(["hello"]);
expect(history.canUndo()).toEqual(false);
@ -104,7 +104,7 @@ describe("editor/history", function () {
parts[0] = "hi you";
expect(history.canUndo()).toEqual(true);
const undoResult = history.undo(model);
const undoResult = history.undo(model) as IHistory;
expect(undoResult.caret).toEqual(spaceCaret);
expect(undoResult.parts).toEqual(["hi "]);
});
@ -118,7 +118,7 @@ describe("editor/history", function () {
const result = history.tryPush(model, new DocumentPosition(0, 0), "insertText", { added: "o" });
expect(result).toEqual(false);
expect(history.canUndo()).toEqual(true);
const undoState = history.undo(model);
const undoState = history.undo(model) as IHistory;
expect(undoState.caret).toEqual(firstCaret);
expect(undoState.parts).toEqual(["hello"]);
});
@ -132,7 +132,7 @@ describe("editor/history", function () {
history.tryPush(model, caret, "insertText", { added: "o" });
history.undo(model);
expect(history.canRedo()).toEqual(true);
const redoState = history.redo();
const redoState = history.redo() as IHistory;
expect(redoState.caret).toBe(caret);
expect(redoState.parts).toEqual(["helloo"]);
});