mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
+111
View File
@@ -0,0 +1,111 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type React from "react";
import { type Part, type CommandPartCreator, type PartCreator } from "./parts";
import type DocumentPosition from "./position";
import { type ICompletion, type ISelectionRange } from "../autocomplete/Autocompleter";
import type Autocomplete from "../components/views/rooms/Autocomplete";
export interface ICallback {
replaceParts?: Part[];
range?: ISelectionRange;
close?: boolean;
}
export type UpdateCallback = (data: ICallback) => void;
export type GetAutocompleterComponent = () => Autocomplete | null;
export type UpdateQuery = (test: string) => Promise<void>;
export default class AutocompleteWrapperModel {
private partIndex?: number;
public constructor(
private updateCallback: UpdateCallback,
private getAutocompleterComponent: GetAutocompleterComponent,
private updateQuery: UpdateQuery,
private partCreator: PartCreator | CommandPartCreator,
) {}
public onEscape(e: KeyboardEvent | React.KeyboardEvent): void {
this.getAutocompleterComponent()?.onEscape(e);
}
public close(): void {
this.updateCallback({ close: true });
}
public hasSelection(): boolean {
return !!this.getAutocompleterComponent()?.hasSelection();
}
public hasCompletions(): boolean {
const ac = this.getAutocompleterComponent();
return !!ac && ac.countCompletions() > 0;
}
public confirmCompletion(): void {
this.getAutocompleterComponent()?.onConfirmCompletion();
this.updateCallback({ close: true });
}
/**
* If there is no current autocompletion, start one and move to the first selection.
*/
public async startSelection(): Promise<void> {
const acComponent = this.getAutocompleterComponent();
if (acComponent && acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered
await acComponent.forceComplete();
}
}
public selectPreviousSelection(): void {
this.getAutocompleterComponent()?.moveSelection(-1);
}
public selectNextSelection(): void {
this.getAutocompleterComponent()?.moveSelection(+1);
}
public onPartUpdate(part: Part, pos: DocumentPosition): Promise<void> {
this.partIndex = pos.index;
return this.updateQuery(part.text);
}
public onComponentConfirm(completion: ICompletion): void {
this.updateCallback({
replaceParts: this.partForCompletion(completion),
close: true,
range: completion.range,
});
}
private partForCompletion(completion: ICompletion): Part[] {
const { completionId } = completion;
const text = completion.completion;
switch (completion.type) {
case "room":
return [this.partCreator.roomPill(text, completionId), this.partCreator.plain(completion.suffix || "")];
case "at-room":
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 || "");
case "command":
// command needs special handling for auto complete, but also renders as plain texts
return [(this.partCreator as CommandPartCreator).command(text)];
default:
// used for emoji and other plain text completion replacement
return this.partCreator.plainWithEmoji(text);
}
}
}
+184
View File
@@ -0,0 +1,184 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { needsCaretNodeBefore, needsCaretNodeAfter } from "./render";
import Range from "./range";
import type EditorModel from "./model";
import { type IPosition } from "./position";
import type DocumentPosition from "./position";
import { type Part, Type } from "./parts";
export type Caret = Range | DocumentPosition;
export function setSelection(editor: HTMLDivElement, model: EditorModel, selection: Range | IPosition): void {
if (selection instanceof Range) {
setDocumentRangeSelection(editor, model, selection);
} else {
setCaretPosition(editor, model, selection);
}
}
function setDocumentRangeSelection(editor: HTMLDivElement, model: EditorModel, range: Range): void {
const sel = document.getSelection()!;
sel.removeAllRanges();
const selectionRange = document.createRange();
const start = getNodeAndOffsetForPosition(editor, model, range.start);
selectionRange.setStart(start.node, start.offset);
const end = getNodeAndOffsetForPosition(editor, model, range.end);
selectionRange.setEnd(end.node, end.offset);
sel.addRange(selectionRange);
}
export function setCaretPosition(editor: HTMLDivElement, model: EditorModel, caretPosition: IPosition): void {
if (model.isEmpty) return; // selection can't possibly be wrong, so avoid a reflow
const range = document.createRange();
const { node, offset } = getNodeAndOffsetForPosition(editor, model, caretPosition);
range.setStart(node, offset);
range.collapse(true);
const sel = document.getSelection()!;
if (sel.rangeCount === 1) {
const existingRange = sel.getRangeAt(0);
if (
existingRange.startContainer === range.startContainer &&
existingRange.startOffset === range.startOffset &&
existingRange.collapsed === range.collapsed
) {
// If the selection matches, it's important to leave it alone.
// Recreating the selection state in at least Chrome can cause
// strange side effects, like touch bar flickering on every key.
// See https://github.com/vector-im/element-web/issues/9299
return;
}
}
sel.removeAllRanges();
sel.addRange(range);
}
function getNodeAndOffsetForPosition(
editor: HTMLDivElement,
model: EditorModel,
position: IPosition,
): {
node: Node;
offset: number;
} {
const { offset, lineIndex, nodeIndex } = getLineAndNodePosition(model, position);
const lineNode = editor.childNodes[lineIndex];
let focusNode;
// empty line with just a <br>
if (nodeIndex === -1) {
focusNode = lineNode;
} else {
focusNode = lineNode.childNodes[nodeIndex];
// make sure we have a text node
if (focusNode.nodeType === Node.ELEMENT_NODE && focusNode.firstChild) {
focusNode = focusNode.firstChild;
}
}
return { node: focusNode, offset };
}
export function getLineAndNodePosition(
model: EditorModel,
caretPosition: IPosition,
): {
offset: number;
lineIndex: number;
nodeIndex: number;
} {
const { parts } = model;
const partIndex = caretPosition.index;
let { offset } = caretPosition;
const lineResult = findNodeInLineForPart(parts, partIndex, offset);
const { lineIndex } = lineResult;
let { nodeIndex } = lineResult;
// we're at an empty line between a newline part
// and another newline part or end/start of parts.
// set offset to 0 so it gets set to the <br> inside the line container
if (nodeIndex === -1) {
offset = 0;
} else {
// move caret out of uneditable part (into caret node, or empty line br) if needed
({ nodeIndex, offset } = moveOutOfUnselectablePart(parts, partIndex, nodeIndex, offset));
}
return { lineIndex, nodeIndex, offset };
}
function findNodeInLineForPart(
parts: Part[],
partIndex: number,
offset: number,
): { lineIndex: number; nodeIndex: number } {
let lineIndex = 0;
let nodeIndex = -1;
let prevPart: Part | undefined;
// go through to parts up till (and including) the index
// to find newline parts
for (let i = 0; i <= partIndex; ++i) {
const part = parts[i];
if (part.type === Type.Newline) {
// don't jump over the linebreak if the offset is before it
if (i == partIndex && offset === 0) {
continue;
}
lineIndex += 1;
nodeIndex = -1;
prevPart = undefined;
} else {
nodeIndex += 1;
if (needsCaretNodeBefore(part, prevPart)) {
nodeIndex += 1;
}
// only jump over caret node if we're not at our destination node already,
// as we'll assume in moveOutOfUnselectablePart that nodeIndex
// refers to the node corresponding to the part,
// and not an adjacent caret node
if (i < partIndex) {
const nextPart = parts[i + 1];
const isLastOfLine = !nextPart || nextPart.type === Type.Newline;
if (needsCaretNodeAfter(part, isLastOfLine)) {
nodeIndex += 1;
}
}
prevPart = part;
}
}
return { lineIndex, nodeIndex };
}
function moveOutOfUnselectablePart(
parts: Part[],
partIndex: number,
nodeIndex: number,
offset: number,
): { offset: number; nodeIndex: number } {
// move caret before or after unselectable part
const part = parts[partIndex];
if (part && !part.acceptsCaret) {
if (offset === 0) {
nodeIndex -= 1;
const prevPart = parts[partIndex - 1];
// if the previous node is a caret node, it's empty
// so the offset can stay at 0
// only when it's not, we need to set the offset
// at the end of the node
if (!needsCaretNodeBefore(part, prevPart)) {
offset = prevPart.text.length;
}
} else {
nodeIndex += 1;
offset = 0;
}
}
return { nodeIndex, offset };
}
+139
View File
@@ -0,0 +1,139 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
import type EditorModel from "./model";
import { Type } from "./parts";
import { type Command, CommandCategories, getCommand } from "../slash-commands/SlashCommands";
import { UserFriendlyError, _t, _td } from "../languageHandler";
import Modal from "../Modal";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import QuestionDialog from "../components/views/dialogs/QuestionDialog";
export function isSlashCommand(model: EditorModel): boolean {
const parts = model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
if (
firstPart.text.startsWith("/") &&
!firstPart.text.startsWith("//") &&
(firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)
) {
return true;
}
}
return false;
}
/**
* Get the slash command and its arguments from the editor model
* @param roomId - The room ID to check whether the command is enabled
* @param model - The editor model
* @returns A tuple of the command (or undefined if not found), the arguments (or undefined), and the full command text
*/
export function getSlashCommand(roomId: string, model: EditorModel): [Command | undefined, string | undefined, string] {
const commandText = model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command and room alias/id for room pills
if (part.type === Type.UserPill || part.type === Type.RoomPill) {
return text + part.resourceId;
}
return text + part.text;
}, "");
const { cmd, args } = getCommand(roomId, commandText);
return [cmd, args, commandText];
}
export async function runSlashCommand(
matrixClient: MatrixClient,
cmd: Command,
args: string | undefined,
roomId: string,
threadId: string | null,
): Promise<[content: RoomMessageEventContent | null, success: boolean]> {
const result = cmd.run(matrixClient, roomId, threadId, args);
let messageContent: RoomMessageEventContent | null = null;
let error: any = result.error;
if (result.promise) {
try {
if (cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects) {
messageContent = (await result.promise) ?? null;
} else {
await result.promise;
}
} catch (err) {
error = err;
}
}
if (error) {
logger.error(`Command failure: ${error}`);
// assume the error is a server error when the command is async
const isServerError = !!result.promise;
const title = isServerError ? _td("slash_command|server_error") : _td("slash_command|command_error");
let errText;
if (typeof error === "string") {
errText = error;
} else if (error instanceof UserFriendlyError) {
errText = error.translatedMessage;
} else if (error.message) {
errText = error.message;
} else {
errText = _t("slash_command|server_error_detail");
}
Modal.createDialog(ErrorDialog, {
title: _t(title),
description: errText,
});
return [null, false];
} else {
logger.log("Command success.");
return [messageContent, true];
}
}
export async function shouldSendAnyway(commandText: string): Promise<boolean> {
// ask the user if their unknown command should be sent as a message
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("slash_command|unknown_command"),
description: (
<div>
<p>{_t("slash_command|unknown_command_detail", { commandText })}</p>
<p>
{_t(
"slash_command|unknown_command_help",
{},
{
code: (t) => <code>{t}</code>,
},
)}
</p>
<p>
{_t(
"slash_command|unknown_command_hint",
{},
{
code: (t) => <code>{t}</code>,
},
)}
</p>
</div>
),
button: _t("slash_command|unknown_command_button"),
});
const [sendAnyway] = await finished;
return sendAnyway || false;
}
+315
View File
@@ -0,0 +1,315 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { checkBlockNode } from "../HtmlUtils";
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
import { type Part, type PartCreator, Type } from "./parts";
import SdkConfig from "../SdkConfig";
import { textToHtmlRainbow } from "../utils/colour";
import { stripPlainReply } from "../utils/Reply";
const LIST_TYPES = ["UL", "OL", "LI"];
// Escapes all markup in the given text
function escape(text: string): string {
return text.replace(/[\\*_[\]`<]|^>/g, (match) => `\\${match}`);
}
// Finds the length of the longest backtick sequence in the given text, used for
// escaping backticks in code blocks
export function longestBacktickSequence(text: string): number {
let length = 0;
let currentLength = 0;
for (const c of text) {
if (c === "`") {
currentLength++;
} else {
length = Math.max(length, currentLength);
currentLength = 0;
}
}
return Math.max(length, currentLength);
}
function isListChild(n: Node): boolean {
return LIST_TYPES.includes(n.parentNode?.nodeName || "");
}
function parseAtRoomMentions(text: string, pc: PartCreator, opts: IParseOptions): Part[] {
const ATROOM = "@room";
const parts: Part[] = [];
text.split(ATROOM).forEach((textPart, i, arr) => {
if (textPart.length) {
parts.push(...pc.plainWithEmoji(opts.shouldEscape ? escape(textPart) : textPart));
}
// it's safe to never append @room after the last textPart
// as split will report an empty string at the end if
// `text` ended in @room.
const isLast = i === arr.length - 1;
if (!isLast) {
parts.push(pc.atRoomPill(ATROOM));
}
});
return parts;
}
function parseLink(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
const { href } = n as HTMLAnchorElement;
const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID
switch (resourceId?.[0]) {
case "@":
return [pc.userPill(n.textContent || "", resourceId)];
case "#":
return [pc.roomPill(resourceId)];
}
const children = Array.from(n.childNodes);
if (href === n.textContent && children.every((c) => c.nodeType === Node.TEXT_NODE)) {
return parseAtRoomMentions(n.textContent, pc, opts);
} else {
return [pc.plain("["), ...parseChildren(n, pc, opts), pc.plain(`](${href})`)];
}
}
function parseImage(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
const { alt, src } = n as HTMLImageElement;
return pc.plainWithEmoji(`![${escape(alt)}](${src})`);
}
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) {
if (className.startsWith("language-") && !className.startsWith("language-_")) {
language = className.slice("language-".length);
break;
}
}
}
const text = n.textContent.replace(/\n$/, "");
// Escape backticks by using even more backticks for the fence if necessary
const fence = "`".repeat(Math.max(3, longestBacktickSequence(text) + 1));
const parts: Part[] = [...pc.plainWithEmoji(fence + language), pc.newline()];
text.split("\n").forEach((line) => {
parts.push(...pc.plainWithEmoji(line));
parts.push(pc.newline());
});
parts.push(pc.plain(fence));
return parts;
}
function parseHeader(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
const depth = parseInt(n.nodeName.slice(1), 10);
const prefix = pc.plain("#".repeat(depth) + " ");
return [prefix, ...parseChildren(n, pc, opts)];
}
function checkIgnored(n: Node): boolean {
if (n.nodeType === Node.TEXT_NODE) {
// Element adds \n text nodes in a lot of places,
// which should be ignored
return n.nodeValue === "\n";
} else if (n.nodeType === Node.ELEMENT_NODE) {
return n.nodeName === "MX-REPLY";
}
return true;
}
function prefixLines(parts: Part[], prefix: string, pc: PartCreator): void {
parts.unshift(pc.plain(prefix));
for (let i = 0; i < parts.length; i++) {
if (parts[i].type === Type.Newline) {
parts.splice(i + 1, 0, pc.plain(prefix));
i += 1;
}
}
}
function parseChildren(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (li: Node) => Part[]): Part[] {
let prev: ChildNode | undefined;
return Array.from(n.childNodes).flatMap((c) => {
const parsed = parseNode(c, pc, opts, mkListItem);
if (parsed.length && prev && (checkBlockNode(prev) || checkBlockNode(c))) {
if (isListChild(c)) {
// Use tighter spacing within lists
parsed.unshift(pc.newline());
} else {
parsed.unshift(pc.newline(), pc.newline());
}
}
if (parsed.length) prev = c;
return parsed;
});
}
function parseNode(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (li: Node) => Part[]): Part[] {
if (checkIgnored(n)) return [];
switch (n.nodeType) {
case Node.TEXT_NODE:
return parseAtRoomMentions(n.nodeValue || "", pc, opts);
case Node.ELEMENT_NODE:
switch (n.nodeName) {
case "H1":
case "H2":
case "H3":
case "H4":
case "H5":
case "H6":
return parseHeader(n, pc, opts);
case "A":
return parseLink(n, pc, opts);
case "IMG":
return parseImage(n, pc, opts);
case "BR":
return [pc.newline()];
case "HR":
return [pc.plain("---")];
case "EM":
return [pc.plain("_"), ...parseChildren(n, pc, opts), pc.plain("_")];
case "STRONG":
return [pc.plain("**"), ...parseChildren(n, pc, opts), pc.plain("**")];
case "DEL":
return [pc.plain("<del>"), ...parseChildren(n, pc, opts), pc.plain("</del>")];
case "S":
return [pc.plain("<s>"), ...parseChildren(n, pc, opts), pc.plain("</s>")];
case "SUB":
return [pc.plain("<sub>"), ...parseChildren(n, pc, opts), pc.plain("</sub>")];
case "SUP":
return [pc.plain("<sup>"), ...parseChildren(n, pc, opts), pc.plain("</sup>")];
case "U":
return [pc.plain("<u>"), ...parseChildren(n, pc, opts), pc.plain("</u>")];
case "PRE":
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);
return pc.plainWithEmoji(`${fence}${n.textContent}${fence}`);
}
case "BLOCKQUOTE": {
const parts = parseChildren(n, pc, opts);
prefixLines(parts, "> ", pc);
return parts;
}
case "LI":
return mkListItem?.(n) ?? parseChildren(n, pc, opts);
case "UL": {
const parts = parseChildren(n, pc, opts, (li) => [pc.plain("- "), ...parseChildren(li, pc, opts)]);
if (isListChild(n)) {
prefixLines(parts, " ", pc);
}
return parts;
}
case "OL": {
let counter = (n as HTMLOListElement).start ?? 1;
const parts = parseChildren(n, pc, opts, (li) => {
const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc, opts)];
counter++;
return parts;
});
if (isListChild(n)) {
prefixLines(parts, " ", pc);
}
return parts;
}
case "DIV":
case "SPAN":
// Math nodes are translated back into delimited latex strings
if ((n as Element).hasAttribute("data-mx-maths")) {
const delims = SdkConfig.get().latex_maths_delims;
const delimLeft =
n.nodeName === "SPAN" ? (delims?.inline?.left ?? "\\(") : (delims?.display?.left ?? "\\[");
const delimRight =
n.nodeName === "SPAN"
? (delims?.inline?.right ?? "\\)")
: (delims?.display?.right ?? "\\]");
const tex = (n as Element).getAttribute("data-mx-maths");
return pc.plainWithEmoji(`${delimLeft}${tex}${delimRight}`);
}
// Spoilers are translated back into their slash command form
else if ((n as Element).hasAttribute("data-mx-spoiler")) {
return [pc.plain("/spoiler "), ...parseChildren(n, pc, opts)];
}
}
}
return parseChildren(n, pc, opts);
}
interface IParseOptions {
isQuotedMessage?: boolean;
shouldEscape?: boolean;
}
function parseHtmlMessage(html: string, pc: PartCreator, opts: IParseOptions): Part[] {
// no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine
const parts = parseNode(new DOMParser().parseFromString(html, "text/html").body, pc, opts);
if (opts.isQuotedMessage) {
prefixLines(parts, "> ", pc);
}
return parts;
}
export function parsePlainTextMessage(body: string, pc: PartCreator, opts: IParseOptions): Part[] {
const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n
return lines.reduce((parts, line, i) => {
if (opts.isQuotedMessage) {
parts.push(pc.plain("> "));
}
parts.push(...parseAtRoomMentions(line, pc, opts));
const isLast = i === lines.length - 1;
if (!isLast) {
parts.push(pc.newline());
}
return parts;
}, [] as Part[]);
}
export function parseEvent(event: MatrixEvent, pc: PartCreator, opts: IParseOptions = { shouldEscape: true }): Part[] {
const content = event.getContent();
let parts: Part[];
const isEmote = content.msgtype === MsgType.Emote;
let isRainbow = false;
if (content.format === "org.matrix.custom.html") {
parts = parseHtmlMessage(content.formatted_body || "", pc, opts);
if (content.body && content.formatted_body && textToHtmlRainbow(content.body) === content.formatted_body) {
isRainbow = true;
}
} else {
let body = content.body || "";
if (event.replyEventId) {
body = stripPlainReply(body);
}
parts = parsePlainTextMessage(body, pc, opts);
}
if (isEmote && isRainbow) {
parts.unshift(pc.plain("/rainbowme "));
} else if (isRainbow) {
parts.unshift(pc.plain("/rainbow "));
} else if (isEmote) {
parts.unshift(pc.plain("/me "));
}
return parts;
}
+67
View File
@@ -0,0 +1,67 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export interface IDiff {
removed?: string;
added?: string;
at?: number;
}
function firstDiff(a: string, b: string): number {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) {
return i;
}
}
return compareLen;
}
function diffStringsAtEnd(oldStr: string, newStr: string): IDiff {
const len = Math.min(oldStr.length, newStr.length);
const startInCommon = oldStr.slice(0, len) === newStr.slice(0, len);
if (startInCommon && oldStr.length > newStr.length) {
return { removed: oldStr.slice(len), at: len };
} else if (startInCommon && oldStr.length < newStr.length) {
return { added: newStr.slice(len), at: len };
} else {
const commonStartLen = firstDiff(oldStr, newStr);
return {
removed: oldStr.slice(commonStartLen),
added: newStr.slice(commonStartLen),
at: commonStartLen,
};
}
}
// assumes only characters have been deleted at one location in the string, and none added
export function diffDeletion(oldStr: string, newStr: string): IDiff {
if (oldStr === newStr) {
return {};
}
const firstDiffIdx = firstDiff(oldStr, newStr);
const amount = oldStr.length - newStr.length;
return { at: firstDiffIdx, removed: oldStr.slice(firstDiffIdx, firstDiffIdx + amount) };
}
/**
* Calculates which string was added and removed around the caret position
* @param {String} oldValue the previous value
* @param {String} newValue the new value
* @param {Number} caretPosition the position of the caret after `newValue` was applied.
* @return {object} an object with `at` as the offset where characters were removed and/or added,
* `added` with the added string (if any), and
* `removed` with the removed string (if any)
*/
export function diffAtCaret(oldValue: string, newValue: string, caretPosition: number): IDiff {
const diffLen = newValue.length - oldValue.length;
const caretPositionBeforeInput = caretPosition - diffLen;
const oldValueBeforeCaret = oldValue.substring(0, caretPositionBeforeInput);
const newValueBeforeCaret = newValue.substring(0, caretPosition);
return diffStringsAtEnd(oldValueBeforeCaret, newValueBeforeCaret);
}
+215
View File
@@ -0,0 +1,215 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { CARET_NODE_CHAR, isCaretNode } from "./render";
import DocumentOffset from "./offset";
import type EditorModel from "./model";
import type Range from "./range";
type Predicate = (node: Node) => boolean;
type Callback = (node: Node) => void;
export function walkDOMDepthFirst(rootNode: Node, enterNodeCallback: Predicate, leaveNodeCallback: Callback): void {
let node = rootNode.firstChild;
while (node && node !== rootNode) {
const shouldDescend = enterNodeCallback(node);
if (shouldDescend && node.firstChild) {
node = node.firstChild;
} else if (node.nextSibling) {
node = node.nextSibling;
} else {
while (node && !node.nextSibling && node !== rootNode) {
node = node.parentElement;
if (node && node !== rootNode) {
leaveNodeCallback(node);
}
}
if (node && node !== rootNode) {
node = node.nextSibling;
}
}
}
}
export function getCaretOffsetAndText(
editor: HTMLDivElement,
sel: Selection,
): {
caret: DocumentOffset;
text: string;
} {
const { offset, text } = getSelectionOffsetAndText(editor, sel.focusNode, sel.focusOffset);
return { caret: offset, text };
}
function tryReduceSelectionToTextNode(
selectionNode: Node | null,
selectionOffset: number,
): {
node: Node | null;
characterOffset: number;
} {
// if selectionNode is an element, the selected location comes after the selectionOffset-th child node,
// which can point past any childNode, in which case, the end of selectionNode is selected.
// we try to simplify this to point at a text node with the offset being
// a character offset within the text node
// Also see https://developer.mozilla.org/en-US/docs/Web/API/Selection
while (selectionNode && selectionNode.nodeType === Node.ELEMENT_NODE) {
const childNodeCount = selectionNode.childNodes.length;
if (childNodeCount) {
if (selectionOffset >= childNodeCount) {
selectionNode = selectionNode.lastChild;
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;
}
} else {
selectionNode = selectionNode.childNodes[selectionOffset];
// this will select the first child node in the next iteration
selectionOffset = 0;
}
} else {
// here node won't be a text node,
// but characterOffset should be 0,
// this happens under some circumstances
// when the editor is empty.
// In this case characterOffset=0 is the right thing to do
break;
}
}
return {
node: selectionNode,
characterOffset: selectionOffset,
};
}
function getSelectionOffsetAndText(
editor: HTMLDivElement,
selectionNode: Node | null,
selectionOffset: number,
): {
offset: DocumentOffset;
text: string;
} {
const { node, characterOffset } = tryReduceSelectionToTextNode(selectionNode, selectionOffset);
const { text, offsetToNode } = getTextAndOffsetToNode(editor, node);
const offset = getCaret(node, offsetToNode, characterOffset);
return { offset, text };
}
// gets the caret position details, ignoring and adjusting to
// the ZWS if you're typing in a caret node
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;
if (node.nodeType === Node.TEXT_NODE && isCaretNode(node.parentElement)) {
const nodeValue = node.nodeValue || "";
const zwsIdx = nodeValue.indexOf(CARET_NODE_CHAR);
if (zwsIdx !== -1 && zwsIdx < offsetWithinNode) {
offsetWithinNode -= 1;
}
// if typing in a caret node, you're either typing before or after the ZWS.
// In both cases, you should be considered at node end because the ZWS is
// not included in the text here, and once the model is updated and rerendered,
// that caret node will be removed.
atNodeEnd = true;
}
return new DocumentOffset(offsetToNode + offsetWithinNode, atNodeEnd);
}
// 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 | null,
): { offsetToNode: number; text: string } {
let offsetToNode = 0;
let foundNode = false;
let text = "";
function enterNodeCallback(node: Node): boolean {
if (!foundNode) {
if (node === selectionNode) {
foundNode = true;
}
}
// usually newlines are entered as new DIV elements,
// but for example while pasting in some browsers, they are still
// converted to BRs, so also take these into account when they
// are not the last element in the DIV.
if (node instanceof HTMLElement && node.tagName === "BR" && node.nextSibling) {
if (!foundNode) {
offsetToNode += 1;
}
text += "\n";
}
const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node);
if (nodeText) {
if (!foundNode) {
offsetToNode += nodeText.length;
}
text += nodeText;
}
return true;
}
function leaveNodeCallback(node: Node): void {
// if this is not the last DIV (which are only used as line containers atm)
// we don't just check if there is a nextSibling because sometimes the caret ends up
// after the last DIV and it creates a newline if you type then,
// whereas you just want it to be appended to the current line
if (
node instanceof HTMLElement &&
node.tagName === "DIV" &&
(<HTMLElement>node.nextSibling)?.tagName === "DIV"
) {
text += "\n";
if (!foundNode) {
offsetToNode += 1;
}
}
}
walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback);
return { text, offsetToNode };
}
// get text value of text node, ignoring ZWS if it's a caret node
function getTextNodeValue(node: Node): string {
const nodeText = node.nodeValue;
if (!nodeText) return "";
// filter out ZWS for caret nodes
if (isCaretNode(node.parentElement)) {
// typed in the caret node, so there is now something more in it than the ZWS
// so filter out the ZWS, and take the typed text into account
if (nodeText.length !== 1) {
return nodeText.replace(CARET_NODE_CHAR, "");
} else {
// only contains ZWS, which is ignored, so return empty string
return "";
}
}
return nodeText;
}
export function getRangeForSelection(editor: HTMLDivElement, model: EditorModel, selection: Selection): Range {
const focusOffset = getSelectionOffsetAndText(editor, selection.focusNode, selection.focusOffset).offset;
const anchorOffset = getSelectionOffsetAndText(editor, selection.anchorNode, selection.anchorOffset).offset;
const focusPosition = focusOffset.asPosition(model);
const anchorPosition = anchorOffset.asPosition(model);
return model.startRange(focusPosition, anchorPosition);
}
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type EditorModel from "./model";
import { type IDiff } from "./diff";
import { type SerializedPart } from "./parts";
import { type Caret } from "./caret";
export interface IHistory {
parts: SerializedPart[];
caret?: Caret;
}
export const MAX_STEP_LENGTH = 10;
export default class HistoryManager {
private stack: IHistory[] = [];
private newlyTypedCharCount = 0;
private currentIndex = -1;
private changedSinceLastPush = false;
private lastCaret?: Caret;
private nonWordBoundarySinceLastPush = false;
private addedSinceLastPush = false;
private removedSinceLastPush = false;
public clear(): void {
this.stack = [];
this.newlyTypedCharCount = 0;
this.currentIndex = -1;
this.changedSinceLastPush = false;
this.lastCaret = undefined;
this.nonWordBoundarySinceLastPush = false;
this.addedSinceLastPush = false;
this.removedSinceLastPush = false;
}
private shouldPush(inputType?: string, diff?: IDiff): boolean {
// right now we can only push a step after
// the input has been applied to the model,
// so we can't push the state before something happened.
// not ideal but changing this would be harder to fit cleanly into
// the editor model.
const isNonBulkInput =
inputType === "insertText" || inputType === "deleteContentForward" || inputType === "deleteContentBackward";
if (diff && isNonBulkInput) {
if (diff.added) {
this.addedSinceLastPush = true;
}
if (diff.removed) {
this.removedSinceLastPush = true;
}
// as long as you've only been adding or removing since the last push
if (this.addedSinceLastPush !== this.removedSinceLastPush) {
// add steps by word boundary, up to MAX_STEP_LENGTH characters
const str = diff.added ? diff.added : diff.removed!;
const isWordBoundary = str === " " || str === "\t" || str === "\n";
if (this.nonWordBoundarySinceLastPush && isWordBoundary) {
return true;
}
if (!isWordBoundary) {
this.nonWordBoundarySinceLastPush = true;
}
this.newlyTypedCharCount += str.length;
return this.newlyTypedCharCount > MAX_STEP_LENGTH;
} else {
// if starting to remove while adding before, or the opposite, push
return true;
}
} else {
// bulk input (paste, ...) should be pushed every time
return true;
}
}
private pushState(model: EditorModel, caret?: Caret): void {
// remove all steps after current step
while (this.currentIndex < this.stack.length - 1) {
this.stack.pop();
}
const parts = model.serializeParts();
this.stack.push({ parts, caret });
this.currentIndex = this.stack.length - 1;
this.lastCaret = undefined;
this.changedSinceLastPush = false;
this.newlyTypedCharCount = 0;
this.nonWordBoundarySinceLastPush = false;
this.addedSinceLastPush = false;
this.removedSinceLastPush = false;
}
// needs to persist parts and caret position
public tryPush(model: EditorModel, caret?: Caret, inputType?: string, diff?: IDiff): boolean {
// ignore state restoration echos.
// these respect the inputType values of the input event,
// but are actually passed in from MessageEditor calling model.reset()
// in the keydown event handler.
if (inputType === "historyUndo" || inputType === "historyRedo") {
return false;
}
const shouldPush = this.shouldPush(inputType, diff);
if (shouldPush) {
this.pushState(model, caret);
} else {
this.lastCaret = caret;
this.changedSinceLastPush = true;
}
return shouldPush;
}
public ensureLastChangesPushed(model: EditorModel): void {
if (this.changedSinceLastPush && this.lastCaret) {
this.pushState(model, this.lastCaret);
}
}
public canUndo(): boolean {
return this.currentIndex >= 1 || this.changedSinceLastPush;
}
public canRedo(): boolean {
return this.currentIndex < this.stack.length - 1;
}
// returns state that should be applied to model
public undo(model: EditorModel): IHistory | void {
if (this.canUndo()) {
this.ensureLastChangesPushed(model);
this.currentIndex -= 1;
return this.stack[this.currentIndex];
}
}
// returns state that should be applied to model
public redo(): IHistory | void {
if (this.canRedo()) {
this.changedSinceLastPush = false;
this.currentIndex += 1;
return this.stack[this.currentIndex];
}
}
}
+479
View File
@@ -0,0 +1,479 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { diffAtCaret, diffDeletion, type IDiff } from "./diff";
import DocumentPosition, { type IPosition } from "./position";
import Range from "./range";
import { type SerializedPart, type Part, type PartCreator } from "./parts";
import { type ICallback } from "./autocomplete";
import type AutocompleteWrapperModel from "./autocomplete";
import type DocumentOffset from "./offset";
import { type Caret } from "./caret";
/**
* @callback ModelCallback
* @param {DocumentPosition?} caretPosition the position where the caret should be position
* @param {string?} inputType the inputType of the DOM input event
* @param {object?} diff an object with `removed` and `added` strings
*/
/**
* @callback TransformCallback
* @param {DocumentPosition?} caretPosition the position where the caret should be position
* @param {string?} inputType the inputType of the DOM input event
* @param {object?} diff an object with `removed` and `added` strings
* @return {Number?} addedLen how many characters were added/removed (-) before the caret during the transformation step.
* This is used to adjust the caret position.
*/
/**
* @callback ManualTransformCallback
* @return the caret position
*/
type TransformCallback = (caretPosition: DocumentPosition, inputType: string | undefined, diff: IDiff) => number | void;
type UpdateCallback = (caret?: Caret, inputType?: string, diff?: IDiff) => void;
type ManualTransformCallback = () => Caret;
export default class EditorModel {
private _parts: Part[];
private readonly _partCreator: PartCreator;
private activePartIdx: number | null = null;
private _autoComplete: AutocompleteWrapperModel | null = null;
private autoCompletePartIdx: number | null = null;
private autoCompletePartCount = 0;
private transformCallback: TransformCallback | null = null;
public constructor(
parts: Part[],
partCreator: PartCreator,
private updateCallback: UpdateCallback | null = null,
) {
this._parts = parts;
this._partCreator = partCreator;
this.transformCallback = null;
}
/**
* Set a callback for the transformation step.
* While processing an update, right before calling the update callback,
* a transform callback can be called, which serves to do modifications
* on the model that can span multiple parts. Also see `startRange()`.
* @param {TransformCallback} transformCallback
*/
public setTransformCallback(transformCallback: TransformCallback): void {
this.transformCallback = transformCallback;
}
/**
* Set a callback for rerendering the model after it has been updated.
* @param {ModelCallback} updateCallback
*/
public setUpdateCallback(updateCallback: UpdateCallback): void {
this.updateCallback = updateCallback;
}
public get partCreator(): PartCreator {
return this._partCreator;
}
public get isEmpty(): boolean {
return this._parts.reduce((len, part) => len + part.text.length, 0) === 0;
}
public clone(): EditorModel {
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 !== null && this.activePartIdx >= index) {
++this.activePartIdx;
}
if (this.autoCompletePartIdx !== null && this.autoCompletePartIdx >= index) {
++this.autoCompletePartIdx;
}
}
private removePart(index: number): void {
this._parts.splice(index, 1);
if (index === this.activePartIdx) {
this.activePartIdx = null;
} else if (this.activePartIdx !== null && this.activePartIdx > index) {
--this.activePartIdx;
}
if (index === this.autoCompletePartIdx) {
this.autoCompletePartIdx = null;
} else if (this.autoCompletePartIdx !== null && this.autoCompletePartIdx > index) {
--this.autoCompletePartIdx;
}
}
private replacePart(index: number, part: Part): void {
this._parts.splice(index, 1, part);
}
public get parts(): Part[] {
return this._parts;
}
public get autoComplete(): AutocompleteWrapperModel | null {
if (this.activePartIdx === this.autoCompletePartIdx) {
return this._autoComplete;
}
return null;
}
public getPositionAtEnd(): DocumentPosition {
if (this._parts.length) {
const index = this._parts.length - 1;
const part = this._parts[index];
return new DocumentPosition(index, part.text.length);
} else {
// part index -1, as there are no parts to point at
return new DocumentPosition(-1, 0);
}
}
public serializeParts(): SerializedPart[] {
return this._parts.map((p) => p.serialize());
}
private diff(newValue: string, inputType: string | undefined, caret: DocumentOffset): IDiff {
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
// can't use caret position with drag and drop
if (inputType === "deleteByDrag") {
return diffDeletion(previousValue, newValue);
} else {
return diffAtCaret(previousValue, newValue, caret.offset);
}
}
public reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string): void {
this._parts = serializedParts
.map((p) => this._partCreator.deserializePart(p))
.filter((p): p is Part => Boolean(p));
if (!caret) {
caret = this.getPositionAtEnd();
}
// close auto complete if open
// this would happen when clearing the composer after sending
// a message with the autocomplete still open
if (this._autoComplete) {
this._autoComplete = null;
this.autoCompletePartIdx = null;
}
this.updateCallback?.(caret, inputType);
}
/**
* Inserts the given parts at the given position.
* Should be run inside a `model.transform()` callback.
* @param {Part[]} parts the parts to replace the range with
* @param {DocumentPosition} position the position to start inserting at
* @return {Number} the amount of characters added
*/
public insert(parts: Part[], position: IPosition): number {
const insertIndex = this.splitAt(position);
let newTextLength = 0;
for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
newTextLength += part.text.length;
this.insertPart(insertIndex + i, part);
}
return newTextLength;
}
public update(newValue: string, inputType: string | undefined, caret: DocumentOffset): Promise<void> {
const diff = this.diff(newValue, inputType, caret);
const position = this.positionForOffset(diff.at || 0, caret.atNodeEnd);
let removedOffsetDecrease = 0;
if (diff.removed) {
removedOffsetDecrease = this.removeText(position, diff.removed.length);
}
let addedLen = 0;
if (diff.added) {
addedLen = this.addText(position, diff.added, inputType);
}
this.mergeAdjacentParts();
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);
if (this.transformCallback) {
const transformAddedLen = this.getTransformAddedLen(newPosition, inputType, diff);
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
}
this.updateCallback?.(newPosition, inputType, diff);
return acPromise;
}
private getTransformAddedLen(newPosition: DocumentPosition, inputType: string | undefined, diff: IDiff): number {
const result = this.transformCallback?.(newPosition, inputType, diff);
return Number.isFinite(result) ? (result as number) : 0;
}
private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean): Promise<void> {
const { index } = pos;
const part = this._parts[index];
if (part) {
if (index !== this.activePartIdx) {
this.activePartIdx = index;
if (canOpenAutoComplete && this.activePartIdx !== this.autoCompletePartIdx) {
// else try to create one
const ac = part.createAutoComplete(this.onAutoComplete);
if (ac) {
// make sure that react picks up the difference between both acs
this._autoComplete = ac;
this.autoCompletePartIdx = index;
this.autoCompletePartCount = 1;
}
}
}
// not autoComplete, only there if active part is autocomplete part
if (this.autoComplete) {
return this.autoComplete.onPartUpdate(part, pos);
}
} else {
this.activePartIdx = null;
this._autoComplete = null;
this.autoCompletePartIdx = null;
this.autoCompletePartCount = 0;
}
return Promise.resolve();
}
private onAutoComplete = ({ replaceParts, close, range }: ICallback): void => {
let pos: DocumentPosition | undefined;
if (replaceParts) {
const autoCompletePartIdx = this.autoCompletePartIdx || 0;
this.replaceRange(
new DocumentPosition(autoCompletePartIdx, range?.start ?? 0),
new DocumentPosition(
autoCompletePartIdx + this.autoCompletePartCount - 1,
range?.end ?? this.parts[autoCompletePartIdx + this.autoCompletePartCount - 1].text.length,
),
replaceParts,
);
this.autoCompletePartCount = replaceParts.length;
const lastPart = replaceParts[replaceParts.length - 1];
// `replaceRange` merges adjacent parts so we need to find it in the new parts list
const lastPartIndex = this.parts.indexOf(lastPart);
pos = new DocumentPosition(lastPartIndex, lastPart.text.length);
}
if (close) {
this._autoComplete = null;
this.autoCompletePartIdx = null;
this.autoCompletePartCount = 0;
}
// rerender even if editor contents didn't change
// to make sure the MessageEditor checks
// model.autoComplete being empty and closes it
this.updateCallback?.(pos);
};
private mergeAdjacentParts(): void {
let prevPart: Part | undefined;
for (let i = 0; i < this._parts.length; ++i) {
let part: Part | undefined = this._parts[i];
const isEmpty = !part.text.length;
const isMerged = !isEmpty && prevPart && prevPart.merge?.(part);
if (isEmpty || isMerged) {
// remove empty or merged part
part = prevPart;
this.removePart(i);
//repeat this index, as it's removed now
--i;
}
prevPart = part;
}
}
/**
* removes `len` amount of characters at `pos`.
* @param {Object} pos
* @param {Number} len
* @return {Number} how many characters before pos were also removed,
* usually because of non-editable parts that can only be removed in their entirety.
*/
public removeText(pos: IPosition, len: number): number {
let { index, offset } = pos;
let removedOffsetDecrease = 0;
while (len > 0) {
// part might be undefined here
let part = this._parts[index];
const amount = Math.min(len, part.text.length - offset);
// don't allow 0 amount deletions
if (amount) {
if (part.canEdit) {
const replaceWith = part.remove(offset, amount);
if (typeof replaceWith === "string") {
this.replacePart(index, this._partCreator.createDefaultPart(replaceWith));
}
part = this._parts[index];
// remove empty part
if (!part.text.length) {
this.removePart(index);
} else {
index += 1;
}
} else {
removedOffsetDecrease += offset;
this.removePart(index);
}
} else {
index += 1;
}
len -= amount;
offset = 0;
}
return removedOffsetDecrease;
}
// return part index where insertion will insert between at offset
private splitAt(pos: IPosition): number {
if (pos.index === -1) {
return 0;
}
if (pos.offset === 0) {
return pos.index;
}
const part = this._parts[pos.index];
if (pos.offset >= part.text.length) {
return pos.index + 1;
}
const secondPart = part.split(pos.offset);
this.insertPart(pos.index + 1, secondPart);
return pos.index + 1;
}
/**
* inserts `str` into the model at `pos`.
* @param {Object} pos
* @param {string} str
* @param {string} inputType the source of the input, see html InputEvent.inputType
* @return {Number} how far from position (in characters) the insertion ended.
* This can be more than the length of `str` when crossing non-editable parts, which are skipped.
*/
private addText(pos: IPosition, str: string, inputType: string | undefined): number {
let { index } = pos;
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)) {
it = undefined;
} else {
const splitPart = part.split(offset);
index += 1;
this.insertPart(index, splitPart);
}
} else if (offset !== 0) {
// not-editable part, caret is not at start,
// so insert str after this part
addLen += part.text.length - offset;
index += 1;
}
} else if (index < 0) {
// if position was not found (index: -1, as happens for empty editor)
// reset it to insert as first part
index = 0;
}
while (it) {
const newPart = this._partCreator.createPartForInput(it, index, inputType);
const oldStr = it;
it = newPart.appendUntilRejected(it, inputType);
if (it === oldStr) {
// nothing changed, break out of this infinite loop and log an error
console.error(`Failed to update model for input (str ${it}) (type ${inputType})`);
break;
}
this.insertPart(index, newPart);
index += 1;
}
return addLen;
}
public positionForOffset(totalOffset: number, atPartEnd = false): DocumentPosition {
let currentOffset = 0;
const index = this._parts.findIndex((part) => {
const partLen = part.text.length;
if (
(atPartEnd && currentOffset + partLen >= totalOffset) ||
(!atPartEnd && currentOffset + partLen > totalOffset)
) {
return true;
}
currentOffset += partLen;
return false;
});
if (index === -1) {
return this.getPositionAtEnd();
} else {
return new DocumentPosition(index, totalOffset - currentOffset);
}
}
/**
* Starts a range, which can span across multiple parts, to find and replace text.
* @param {DocumentPosition} positionA a boundary of the range
* @param {DocumentPosition?} positionB the other boundary of the range, optional
* @return {Range}
*/
public startRange(positionA: DocumentPosition, positionB = positionA): Range {
return new Range(this, positionA, positionB);
}
public replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]): void {
// 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);
const newStartPartIndex = this.splitAt(startPosition);
// convert it back to position once split at start
endPosition = endOffset.asPosition(this);
const newEndPartIndex = this.splitAt(endPosition);
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
this.removePart(i);
}
let insertIdx = newStartPartIndex;
for (const part of parts) {
this.insertPart(insertIdx, part);
insertIdx += 1;
}
this.mergeAdjacentParts();
}
/**
* Performs a transformation not part of an update cycle.
* Modifying the model should only happen inside a transform call if not part of an update call.
* @param {ManualTransformCallback} callback to run the transformations in
* @return {Promise} a promise when auto-complete (if applicable) is done updating
*/
public transform(callback: ManualTransformCallback): Promise<void> {
const pos = callback();
let acPromise: Promise<void> | null;
if (!(pos instanceof Range)) {
acPromise = this.setActivePart(pos, true);
} else {
acPromise = Promise.resolve();
}
this.updateCallback?.(pos);
return acPromise;
}
}
+25
View File
@@ -0,0 +1,25 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type EditorModel from "./model";
import type DocumentPosition from "./position";
export default class DocumentOffset {
public constructor(
public offset: number,
public readonly atNodeEnd: boolean,
) {}
public asPosition(model: EditorModel): DocumentPosition {
return model.positionForOffset(this.offset, this.atNodeEnd);
}
public add(delta: number, atNodeEnd = false): DocumentOffset {
return new DocumentOffset(this.offset + delta, atNodeEnd);
}
}
+308
View File
@@ -0,0 +1,308 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type Range from "./range";
import { type Part, Type } from "./parts";
import { Formatting } from "../components/views/rooms/MessageComposerFormatBar";
import { longestBacktickSequence } from "./deserialize";
/**
* Some common queries and transformations on the editor model
*/
/**
* Formats a given range with a given action
* @param {Range} range the range that should be formatted
* @param {Formatting} action the action that should be performed on the range
*/
export function formatRange(range: Range, action: Formatting): void {
// If the selection was empty we select the current word instead
if (range.wasInitializedEmpty()) {
selectRangeOfWordAtCaret(range);
} else {
// Remove whitespace or new lines in our selection
range.trim();
}
// Edge case when just selecting whitespace or new line.
// There should be no reason to format whitespace, so we can just return.
if (range.length === 0) {
return;
}
switch (action) {
case Formatting.Bold:
toggleInlineFormat(range, "**");
break;
case Formatting.Italics:
toggleInlineFormat(range, "*");
break;
case Formatting.Strikethrough:
toggleInlineFormat(range, "<del>", "</del>");
break;
case Formatting.Code:
formatRangeAsCode(range);
break;
case Formatting.Quote:
formatRangeAsQuote(range);
break;
case Formatting.InsertLink:
formatRangeAsLink(range);
break;
}
}
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void {
const { model } = range;
model.transform(() => {
const oldLen = range.length;
const addedLen = range.replace(newParts);
const firstOffset = range.start.asOffset(model);
const lastOffset = firstOffset.add(oldLen + addedLen);
return model.startRange(firstOffset.asPosition(model), lastOffset.asPosition(model));
});
}
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0, atNodeEnd = false): void {
const { model } = range;
model.transform(() => {
const oldLen = range.length;
const addedLen = range.replace(newParts);
const firstOffset = range.start.asOffset(model);
const lastOffset = firstOffset.add(oldLen + addedLen + offset, atNodeEnd);
return lastOffset.asPosition(model);
});
}
/**
* Replaces a range with formatting or removes existing formatting and
* positions the cursor with respect to the prefix and suffix length.
* @param {Range} range the previous value
* @param {Part[]} newParts the new value
* @param {boolean} rangeHasFormatting the new value
* @param {number} prefixLength the length of the formatting prefix
* @param {number} suffixLength the length of the formatting suffix, defaults to prefix length
*/
export function replaceRangeAndAutoAdjustCaret(
range: Range,
newParts: Part[],
rangeHasFormatting = false,
prefixLength: number,
suffixLength = prefixLength,
): void {
const { model } = range;
const lastStartingPosition = range.getLastStartingPosition();
const relativeOffset = lastStartingPosition.offset - range.start.offset;
const distanceFromEnd = range.length - relativeOffset;
// Handle edge case where the caret is located within the suffix or prefix
if (rangeHasFormatting) {
if (relativeOffset < prefixLength) {
// Was the caret at the left format string?
replaceRangeAndMoveCaret(range, newParts, -(range.length - 2 * suffixLength));
return;
}
if (distanceFromEnd < suffixLength) {
// Was the caret at the right format string?
replaceRangeAndMoveCaret(range, newParts, 0, true);
return;
}
}
// Calculate new position with respect to the previous position
model.transform(() => {
const offsetDirection = Math.sign(range.replace(newParts)); // Compensates for shrinkage or expansion
const atEnd = distanceFromEnd === suffixLength;
return lastStartingPosition
.asOffset(model)
.add(offsetDirection * prefixLength, atEnd)
.asPosition(model);
});
}
const isFormattable = (_index: number, offset: number, part: Part): boolean => {
return part.text[offset] !== " " && part.type === Type.Plain;
};
export function selectRangeOfWordAtCaret(range: Range): void {
// Select right side of word
range.expandForwardsWhile(isFormattable);
// Select left side of word
range.expandBackwardsWhile(isFormattable);
// Trim possibly selected new lines
range.trim();
}
export function rangeStartsAtBeginningOfLine(range: Range): boolean {
const { model } = range;
const startsWithPartial = range.start.offset !== 0;
const isFirstPart = range.start.index === 0;
const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === Type.Newline;
return !startsWithPartial && (isFirstPart || previousIsNewline);
}
export function rangeEndsAtEndOfLine(range: Range): boolean {
const { model } = range;
const lastPart = model.parts[range.end.index];
const endsWithPartial = range.end.offset !== lastPart.text.length;
const isLastPart = range.end.index === model.parts.length - 1;
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === Type.Newline;
return !endsWithPartial && (isLastPart || nextIsNewline);
}
export function formatRangeAsQuote(range: Range): void {
const { model, parts } = range;
const { partCreator } = model;
for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
if (part.type === Type.Newline) {
parts.splice(i + 1, 0, partCreator.plain("> "));
}
}
parts.unshift(partCreator.plain("> "));
if (!rangeStartsAtBeginningOfLine(range)) {
parts.unshift(partCreator.newline());
}
if (!rangeEndsAtEndOfLine(range)) {
parts.push(partCreator.newline());
}
parts.push(partCreator.newline());
replaceRangeAndExpandSelection(range, parts);
}
export function formatRangeAsCode(range: Range): void {
const { model, parts } = range;
const { partCreator } = model;
const hasBlockFormatting =
range.length > 0 && range.text.startsWith("```") && range.text.endsWith("```") && range.text.includes("\n");
const needsBlockFormatting = parts.some((p) => p.type === Type.Newline);
if (hasBlockFormatting) {
parts.shift();
parts.pop();
if (parts[0]?.text === "\n" && parts[parts.length - 1]?.text === "\n") {
parts.shift();
parts.pop();
}
} else if (needsBlockFormatting) {
parts.unshift(partCreator.plain("```"), partCreator.newline());
if (!rangeStartsAtBeginningOfLine(range)) {
parts.unshift(partCreator.newline());
}
parts.push(partCreator.newline(), partCreator.plain("```"));
if (!rangeEndsAtEndOfLine(range)) {
parts.push(partCreator.newline());
}
} else {
const fenceLen = longestBacktickSequence(range.text);
const hasInlineFormatting = range.text.startsWith("`") && range.text.endsWith("`");
//if it's already formatted untoggle based on fenceLen which returns the max. num of backtick within a text else increase the fence backticks with a factor of 1.
toggleInlineFormat(range, "`".repeat(hasInlineFormatting ? fenceLen : fenceLen + 1));
return;
}
replaceRangeAndExpandSelection(range, parts);
}
export function formatRangeAsLink(range: Range, text?: string): void {
const { model } = range;
const { partCreator } = model;
const linkRegex = /\[(.*?)]\(.*?\)/g;
const isFormattedAsLink = linkRegex.test(range.text);
if (isFormattedAsLink) {
const linkDescription = range.text.replace(linkRegex, "$1");
const newParts = [partCreator.plain(linkDescription)];
replaceRangeAndMoveCaret(range, newParts, 0);
} else {
// We set offset to -1 here so that the caret lands between the brackets
replaceRangeAndMoveCaret(range, [partCreator.plain("[" + range.text + "]" + "(" + (text ?? "") + ")")], -1);
}
}
// parts helper methods
const isBlank = (part: Part): boolean => !part.text || !/\S/.test(part.text);
const isNL = (part: Part): boolean => part.type === Type.Newline;
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix): void {
const { model, parts } = range;
const { partCreator } = model;
// compute paragraph [start, end] indexes
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
for (let i = 2; i < parts.length; i++) {
// paragraph breaks can be denoted in a multitude of ways,
// - 2 newline parts in sequence
// - newline part, plain(<empty or just spaces>), newline part
// bump startIndex onto the first non-blank after the paragraph ending
if (isBlank(parts[i - 2]) && isNL(parts[i - 1]) && !isNL(parts[i]) && !isBlank(parts[i])) {
startIndex = i;
}
// if at a paragraph break, store the indexes of the paragraph
if (isNL(parts[i - 1]) && isNL(parts[i])) {
paragraphIndexes.push([startIndex, i - 1]);
startIndex = i + 1;
} else if (isNL(parts[i - 2]) && isBlank(parts[i - 1]) && isNL(parts[i])) {
paragraphIndexes.push([startIndex, i - 2]);
startIndex = i + 1;
}
}
const lastNonEmptyPart = parts.map(isBlank).lastIndexOf(false);
// If we have not yet included the final paragraph then add it now
if (startIndex <= lastNonEmptyPart) {
paragraphIndexes.push([startIndex, lastNonEmptyPart + 1]);
}
// keep track of how many things we have inserted as an offset:=0
let offset = 0;
paragraphIndexes.forEach(([startIdx, endIdx]) => {
// for each paragraph apply the same rule
const base = startIdx + offset;
const index = endIdx + offset;
const isFormatted =
index - base > 0 && parts[base].text.startsWith(prefix) && parts[index - 1].text.endsWith(suffix);
if (isFormatted) {
// remove prefix and suffix formatting string
const partWithoutPrefix = parts[base].serialize();
partWithoutPrefix.text = partWithoutPrefix.text.slice(prefix.length);
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);
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));
offset += 2; // offset index to account for the two items we just spliced in
}
});
// If the user didn't select something initially, we want to just restore
// the caret position instead of making a new selection.
if (range.wasInitializedEmpty() && prefix === suffix) {
// Check if we need to add a offset for a toggle or untoggle
const hasFormatting = range.text.startsWith(prefix) && range.text.endsWith(suffix);
replaceRangeAndAutoAdjustCaret(range, parts, hasFormatting, prefix.length);
} else {
replaceRangeAndExpandSelection(range, parts);
}
}
+722
View File
@@ -0,0 +1,722 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
import AutocompleteWrapperModel, {
type GetAutocompleterComponent,
type UpdateCallback,
type UpdateQuery,
} from "./autocomplete";
import { EMOJI_REGEX, unicodeToShortcode } from "../HtmlUtils";
import * as Avatar from "../Avatar";
import defaultDispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
import SettingsStore from "../settings/SettingsStore";
import { getFirstGrapheme, graphemeSegmenter } from "../utils/strings";
const REGIONAL_EMOJI_SEPARATOR = String.fromCodePoint(0x200b);
interface ISerializedPart {
type: Type.Plain | Type.Newline | Type.Emoji | Type.Command | Type.PillCandidate;
text: string;
}
interface ISerializedPillPart {
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
text: string;
resourceId?: string;
}
export type SerializedPart = ISerializedPart | ISerializedPillPart;
export enum Type {
Plain = "plain",
Newline = "newline",
Emoji = "emoji",
Command = "command",
UserPill = "user-pill",
RoomPill = "room-pill",
AtRoomPill = "at-room-pill",
PillCandidate = "pill-candidate",
}
interface IBasePart {
text: string;
type: Type.Plain | Type.Newline | Type.Emoji;
canEdit: boolean;
acceptsCaret: boolean;
createAutoComplete(updateCallback: UpdateCallback): void;
serialize(): SerializedPart;
remove(offset: number, len: number): string | undefined;
split(offset: number): IBasePart;
validateAndInsert(offset: number, str: string, inputType: string | undefined): boolean;
appendUntilRejected(str: string, inputType: string | undefined): string | undefined;
updateDOMNode(node: Node): void;
canUpdateDOMNode(node: Node): boolean;
toDOMNode(): Node;
merge?(part: Part): boolean;
}
interface IPillCandidatePart extends Omit<IBasePart, "type" | "createAutoComplete"> {
type: Type.PillCandidate | Type.Command;
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel | undefined;
}
interface IPillPart extends Omit<IBasePart, "type" | "resourceId"> {
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
resourceId: string;
}
export type Part = IBasePart | IPillCandidatePart | IPillPart;
abstract class BasePart {
protected _text: string;
public constructor(text = "") {
this._text = text;
}
// chr can also be a grapheme cluster
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
return true;
}
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
public merge(part: Part): boolean {
return false;
}
public split(offset: number): IBasePart {
const splitText = this.text.slice(offset);
this._text = this.text.slice(0, offset);
return new PlainPart(splitText);
}
// removes len chars, or returns the plain text this part should be replaced with
// if the part would become invalid if it removed everything.
public remove(offset: number, len: number): string | undefined {
// validate
const strWithRemoval = this.text.slice(0, offset) + this.text.slice(offset + len);
for (let i = offset; i < len + offset; ++i) {
const chr = this.text.charAt(i);
if (!this.acceptsRemoval(i, chr)) {
return strWithRemoval;
}
}
this._text = strWithRemoval;
}
// append str, returns the remaining string if a character was rejected.
public appendUntilRejected(str: string, inputType: string): string | undefined {
const offset = this.text.length;
// Take a copy as we will be taking chunks off the start of the string as we process them
// To only need to grapheme split the bits of the string we're working on.
let buffer = str;
while (buffer) {
const char = getFirstGrapheme(buffer);
if (!this.acceptsInsertion(char, offset + str.length - buffer.length, inputType)) {
break;
}
buffer = buffer.slice(char.length);
}
this._text += str.slice(0, str.length - buffer.length);
return buffer || undefined;
}
// 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.
public validateAndInsert(offset: number, str: string, inputType: string): boolean {
for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i);
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
return false;
}
}
const beforeInsert = this._text.slice(0, offset);
const afterInsert = this._text.slice(offset);
this._text = beforeInsert + str + afterInsert;
return true;
}
public createAutoComplete(updateCallback: UpdateCallback): void {}
protected trim(len: number): string {
const remaining = this._text.slice(len);
this._text = this._text.slice(0, len);
return remaining;
}
public get text(): string {
return this._text;
}
public abstract get type(): Type;
public get canEdit(): boolean {
return true;
}
public get acceptsCaret(): boolean {
return this.canEdit;
}
public toString(): string {
return `${this.type}(${this.text})`;
}
public serialize(): SerializedPart {
return {
type: this.type as ISerializedPart["type"],
text: this.text,
};
}
public abstract updateDOMNode(node: Node): void;
public abstract canUpdateDOMNode(node: Node): boolean;
public abstract toDOMNode(): Node;
}
abstract class PlainBasePart extends BasePart {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (chr === "\n" || EMOJI_REGEX.test(chr)) {
return false;
}
// when not pasting or dropping text, reject characters that should start a pill candidate
if (inputType !== "insertFromPaste" && inputType !== "insertFromDrop") {
if (chr !== "@" && chr !== "#" && chr !== ":" && chr !== "+") {
return true;
}
// split if we are at the beginning of the part text
if (offset === 0) {
return false;
}
// or split if the previous character is a space or regional emoji separator
// or if it is a + and this is a :
return (
this._text[offset - 1] !== " " &&
this._text[offset - 1] !== REGIONAL_EMOJI_SEPARATOR &&
(this._text[offset - 1] !== "+" || chr !== ":")
);
}
return true;
}
public toDOMNode(): Node {
return document.createTextNode(this.text);
}
public merge(part: Part): boolean {
if (part.type === this.type) {
this._text = this.text + part.text;
return true;
}
return false;
}
public updateDOMNode(node: Node): void {
if (node.textContent !== this.text) {
node.textContent = this.text;
}
}
public canUpdateDOMNode(node: Node): boolean {
return node.nodeType === Node.TEXT_NODE;
}
}
// exported for unit tests, should otherwise only be used through PartCreator
export class PlainPart extends PlainBasePart implements IBasePart {
public get type(): IBasePart["type"] {
return Type.Plain;
}
}
export abstract class PillPart extends BasePart implements IPillPart {
public constructor(
public resourceId: string,
label: string,
) {
super(label);
}
protected acceptsInsertion(chr: string): boolean {
return chr !== " ";
}
protected acceptsRemoval(position: number, chr: string): boolean {
return position !== 0; //if you remove initial # or @, pill should become plain
}
public toDOMNode(): Node {
const container = document.createElement("span");
container.setAttribute("spellcheck", "false");
container.setAttribute("contentEditable", "false");
if (this.onClick) container.onclick = this.onClick;
container.className = this.className;
container.appendChild(document.createTextNode(this.text));
this.setAvatar(container);
return container;
}
public updateDOMNode(node: HTMLElement): void {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
textNode.textContent = this.text;
}
if (node.className !== this.className) {
node.className = this.className;
}
if (this.onClick && node.onclick !== this.onClick) {
node.onclick = this.onClick;
}
this.setAvatar(node);
}
public canUpdateDOMNode(node: HTMLElement): boolean {
return (
node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === "SPAN" &&
node.childNodes.length === 1 &&
node.childNodes[0].nodeType === Node.TEXT_NODE
);
}
// helper method for subclasses
protected setAvatarVars(
node: HTMLElement,
avatarUrl: string,
initialLetter: string,
avatarTextColor?: string,
): void {
const avatarBackground = `url('${avatarUrl}')`;
const avatarLetter = `'${initialLetter}'`;
// check if the value is changing,
// otherwise the avatars flicker on every keystroke while updating.
if (node.style.getPropertyValue("--avatar-background") !== avatarBackground) {
node.style.setProperty("--avatar-background", avatarBackground);
}
if (node.style.getPropertyValue("--avatar-letter") !== avatarLetter) {
node.style.setProperty("--avatar-letter", avatarLetter);
}
if (avatarTextColor && node.style.getPropertyValue("--avatar-color") !== avatarTextColor) {
node.style.setProperty("--avatar-color", avatarTextColor);
}
}
public serialize(): ISerializedPillPart {
return {
type: this.type,
text: this.text,
resourceId: this.resourceId,
};
}
public get canEdit(): boolean {
return false;
}
public abstract get type(): IPillPart["type"];
protected abstract get className(): string;
protected onClick?: () => void;
protected abstract setAvatar(node: HTMLElement): void;
}
class NewlinePart extends BasePart implements IBasePart {
protected acceptsInsertion(chr: string, offset: number): boolean {
return offset === 0 && chr === "\n";
}
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
public toDOMNode(): Node {
return document.createElement("br");
}
public merge(): boolean {
return false;
}
public updateDOMNode(): void {}
public canUpdateDOMNode(node: HTMLElement): boolean {
return node.tagName === "BR";
}
public get type(): IBasePart["type"] {
return Type.Newline;
}
// this makes the cursor skip this part when it is inserted
// 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 it can only be one character long. This caused #9741.
public get canEdit(): boolean {
return false;
}
}
export class EmojiPart extends BasePart implements IBasePart {
protected acceptsInsertion(chr: string, offset: number): boolean {
return EMOJI_REGEX.test(chr);
}
protected acceptsRemoval(position: number, chr: string): boolean {
return false;
}
public toDOMNode(): Node {
const span = document.createElement("span");
span.className = "mx_Emoji";
span.setAttribute("title", unicodeToShortcode(this.text));
span.appendChild(document.createTextNode(this.text));
return span;
}
public updateDOMNode(node: HTMLElement): void {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
node.setAttribute("title", unicodeToShortcode(this.text));
textNode.textContent = this.text;
}
}
public canUpdateDOMNode(node: HTMLElement): boolean {
return node.className === "mx_Emoji";
}
public get type(): IBasePart["type"] {
return Type.Emoji;
}
public get canEdit(): boolean {
return false;
}
public get acceptsCaret(): boolean {
return true;
}
}
class RoomPillPart extends PillPart {
public constructor(
resourceId: string,
label: string,
private room?: Room,
) {
super(resourceId, label);
}
protected setAvatar(node: HTMLElement): void {
let initialLetter = "";
let avatarUrl = Avatar.avatarUrlForRoom(this.room ?? null, 16, 16, "crop");
let avatarTextColor: string | undefined;
if (!avatarUrl) {
initialLetter = Avatar.getInitialLetter(this.room?.name || this.resourceId) ?? "";
avatarUrl = Avatar.defaultAvatarUrlForString(this.room?.roomId ?? this.resourceId);
avatarTextColor = Avatar.getAvatarTextColor(this.room?.roomId ?? this.resourceId);
}
this.setAvatarVars(node, avatarUrl, initialLetter, avatarTextColor);
}
public get type(): IPillPart["type"] {
return Type.RoomPill;
}
protected get className(): string {
return "mx_Pill " + (this.room?.isSpaceRoom() ? "mx_SpacePill" : "mx_RoomPill");
}
}
class AtRoomPillPart extends RoomPillPart {
public constructor(text: string, room: Room) {
super(text, text, room);
}
public get type(): IPillPart["type"] {
return Type.AtRoomPill;
}
public serialize(): ISerializedPillPart {
return {
type: this.type,
text: this.text,
};
}
}
class UserPillPart extends PillPart {
public constructor(
userId: string,
displayName: string,
private member?: RoomMember,
) {
super(userId, displayName);
}
public get type(): IPillPart["type"] {
return Type.UserPill;
}
protected get className(): string {
return "mx_UserPill mx_Pill";
}
protected setAvatar(node: HTMLElement): void {
if (!this.member) {
return;
}
const name = this.member.name || this.member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
const avatarUrl = Avatar.avatarUrlForMember(this.member, 16, 16, "crop");
let initialLetter = "";
let avatarTextColor: string | undefined;
if (avatarUrl === defaultAvatarUrl) {
initialLetter = Avatar.getInitialLetter(name) ?? "";
avatarTextColor = Avatar.getAvatarTextColor(this.member.userId);
}
this.setAvatarVars(node, avatarUrl, initialLetter, avatarTextColor);
}
protected onClick = (): void => {
defaultDispatcher.dispatch({
action: Action.ViewUser,
member: this.member,
});
};
}
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
public constructor(
text: string,
private autoCompleteCreator: IAutocompleteCreator,
) {
super(text);
}
public createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel | undefined {
return this.autoCompleteCreator.create?.(updateCallback);
}
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (offset === 0) {
return true;
} else {
return super.acceptsInsertion(chr, offset, inputType);
}
}
public merge(): boolean {
return false;
}
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
public get type(): IPillCandidatePart["type"] {
return Type.PillCandidate;
}
}
export function getAutoCompleteCreator(getAutocompleterComponent: GetAutocompleterComponent, updateQuery: UpdateQuery) {
return (partCreator: PartCreator) => {
return (updateCallback: UpdateCallback) => {
return new AutocompleteWrapperModel(updateCallback, getAutocompleterComponent, updateQuery, partCreator);
};
};
}
type AutoCompleteCreator = ReturnType<typeof getAutoCompleteCreator>;
interface IAutocompleteCreator {
create: ((updateCallback: UpdateCallback) => AutocompleteWrapperModel) | undefined;
}
export class PartCreator {
protected readonly autoCompleteCreator: IAutocompleteCreator;
public constructor(
private readonly room: Room,
private readonly client: MatrixClient,
autoCompleteCreator: AutoCompleteCreator | null = null,
) {
// 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
this.autoCompleteCreator = { create: autoCompleteCreator?.(this) };
}
public setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator): void {
this.autoCompleteCreator.create = autoCompleteCreator(this);
}
public createPartForInput(input: string, partIndex: number, inputType?: string): Part {
switch (input[0]) {
case "#":
case "@":
case ":":
case "+":
return this.pillCandidate("");
case "\n":
return new NewlinePart();
default:
if (EMOJI_REGEX.test(getFirstGrapheme(input))) {
return new EmojiPart();
}
return new PlainPart();
}
}
public createDefaultPart(text: string): Part {
return this.plain(text);
}
public deserializePart(part: SerializedPart): Part | undefined {
switch (part.type) {
case Type.Plain:
return this.plain(part.text);
case Type.Newline:
return this.newline();
case Type.Emoji:
return this.emoji(part.text);
case Type.AtRoomPill:
return this.atRoomPill(part.text);
case Type.PillCandidate:
return this.pillCandidate(part.text);
case Type.RoomPill:
return part.resourceId ? this.roomPill(part.resourceId) : undefined;
case Type.UserPill:
return part.resourceId ? this.userPill(part.text, part.resourceId) : undefined;
}
}
public plain(text: string): PlainPart {
return new PlainPart(text);
}
public newline(): NewlinePart {
return new NewlinePart("\n");
}
public emoji(text: string): EmojiPart {
return new EmojiPart(text);
}
public pillCandidate(text: string): PillCandidatePart {
return new PillCandidatePart(text, this.autoCompleteCreator);
}
public roomPill(alias: string, roomId?: string): RoomPillPart {
let room: Room | undefined;
if (roomId || alias[0] !== "#") {
room = this.client.getRoom(roomId || alias) ?? undefined;
} else {
room = this.client.getRooms().find((r) => {
return r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias);
});
}
return new RoomPillPart(alias, room ? room.name : alias, room);
}
public atRoomPill(text: string): AtRoomPillPart {
return new AtRoomPillPart(text, this.room);
}
public userPill(displayName: string, userId: string): UserPillPart {
const member = this.room.getMember(userId);
return new UserPillPart(userId, displayName, member || undefined);
}
private static isRegionalIndicator(c: string): boolean {
const codePoint = c.codePointAt(0) ?? 0;
return codePoint != 0 && c.length == 2 && 0x1f1e6 <= codePoint && codePoint <= 0x1f1ff;
}
public plainWithEmoji(text: string): (PlainPart | EmojiPart)[] {
const parts: (PlainPart | EmojiPart)[] = [];
let plainText = "";
for (const data of graphemeSegmenter.segment(text)) {
if (EMOJI_REGEX.test(data.segment)) {
if (plainText) {
parts.push(this.plain(plainText));
plainText = "";
}
parts.push(this.emoji(data.segment));
if (PartCreator.isRegionalIndicator(text)) {
parts.push(this.plain(REGIONAL_EMOJI_SEPARATOR));
}
} else {
plainText += data.segment;
}
}
if (plainText) {
parts.push(this.plain(plainText));
}
return parts;
}
public createMentionParts(
insertTrailingCharacter: boolean,
displayName: string,
userId: string,
): [UserPillPart, PlainPart] {
const pill = this.userPill(displayName, userId);
if (!SettingsStore.getValue("MessageComposerInput.insertTrailingColon")) {
insertTrailingCharacter = false;
}
const postfix = this.plain(insertTrailingCharacter ? ": " : " ");
return [pill, postfix];
}
}
// part creator that support auto complete for /commands,
// used in SendMessageComposer
export class CommandPartCreator extends PartCreator {
public createPartForInput(text: string, partIndex: number): Part {
// at beginning and starts with /? create
if (partIndex === 0 && text[0] === "/") {
// text will be inserted by model, so pass empty string
return this.command("");
} else {
return super.createPartForInput(text, partIndex);
}
}
public command(text: string): CommandPart {
return new CommandPart(text, this.autoCompleteCreator);
}
public deserializePart(part: SerializedPart): Part | undefined {
if (part.type === Type.Command) {
return this.command(part.text);
} else {
return super.deserializePart(part);
}
}
}
class CommandPart extends PillCandidatePart {
public get type(): IPillCandidatePart["type"] {
return Type.Command;
}
}
+134
View File
@@ -0,0 +1,134 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import DocumentOffset from "./offset";
import type EditorModel from "./model";
import { type Part } from "./parts";
export interface IPosition {
index: number;
offset: number;
}
type Callback = (part: Part, startIdx: number, endIdx: number) => void;
export type Predicate = (index: number, offset: number, part: Part) => boolean;
export default class DocumentPosition implements IPosition {
public constructor(
public readonly index: number,
public readonly offset: number,
) {}
public compare(otherPos: DocumentPosition): number {
if (this.index === otherPos.index) {
return this.offset - otherPos.offset;
} else {
return this.index - otherPos.index;
}
}
public iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback): void {
if (this.index === -1 || other.index === -1) {
return;
}
const [startPos, endPos] = this.compare(other) < 0 ? [this, other] : [other, this];
if (startPos.index === endPos.index) {
callback(model.parts[this.index], startPos.offset, endPos.offset);
} else {
const firstPart = model.parts[startPos.index];
callback(firstPart, startPos.offset, firstPart.text.length);
for (let i = startPos.index + 1; i < endPos.index; ++i) {
const part = model.parts[i];
callback(part, 0, part.text.length);
}
const lastPart = model.parts[endPos.index];
callback(lastPart, 0, endPos.offset);
}
}
public forwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
if (this.index === -1) {
return this;
}
let { index, offset } = this;
const { parts } = model;
while (index < parts.length) {
const part = parts[index];
while (offset < part.text.length) {
if (!predicate(index, offset, part)) {
return new DocumentPosition(index, offset);
}
offset += 1;
}
// end reached
if (index === parts.length - 1) {
return new DocumentPosition(index, offset);
} else {
index += 1;
offset = 0;
}
}
return this; // impossible but Typescript doesn't believe us
}
public backwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
if (this.index === -1) {
return this;
}
let { index, offset } = this;
const parts = model.parts;
while (index >= 0) {
const part = parts[index];
while (offset > 0) {
if (!predicate(index, offset - 1, part)) {
return new DocumentPosition(index, offset);
}
offset -= 1;
}
// start reached
if (index === 0) {
return new DocumentPosition(index, offset);
} else {
index -= 1;
offset = parts[index].text.length;
}
}
return this; // impossible but Typescript doesn't believe us
}
public asOffset(model: EditorModel): DocumentOffset {
if (this.index === -1) {
return new DocumentOffset(0, true);
}
let offset = 0;
for (let i = 0; i < this.index; ++i) {
offset += model.parts[i].text.length;
}
offset += this.offset;
const lastPart = model.parts[this.index];
const atEnd = !lastPart || offset >= lastPart.text.length; // if no last part, we're at the end
return new DocumentOffset(offset, atEnd);
}
public isAtEnd(model: EditorModel): boolean {
if (model.parts.length === 0) {
return true;
}
const lastPartIdx = model.parts.length - 1;
const lastPart = model.parts[lastPartIdx];
return this.index === lastPartIdx && this.offset === lastPart.text.length;
}
public isAtStart(): boolean {
return this.index === 0 && this.offset === 0;
}
}
+138
View File
@@ -0,0 +1,138 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type EditorModel from "./model";
import { type Predicate } from "./position";
import type DocumentPosition from "./position";
import { type Part } from "./parts";
const whitespacePredicate: Predicate = (index, offset, part) => {
return part.text[offset].trim() === "";
};
export default class Range {
private _start: DocumentPosition;
private _end: DocumentPosition;
private _lastStart: DocumentPosition;
private _initializedEmpty: boolean;
public constructor(
public readonly model: EditorModel,
positionA: DocumentPosition,
positionB = positionA,
) {
const bIsLarger = positionA.compare(positionB) < 0;
this._start = bIsLarger ? positionA : positionB;
this._end = bIsLarger ? positionB : positionA;
this._lastStart = this._start;
this._initializedEmpty = this._start.index === this._end.index && this._start.offset == this._end.offset;
}
public moveStartForwards(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public wasInitializedEmpty(): boolean {
return this._initializedEmpty;
}
public setWasEmpty(value: boolean): void {
this._initializedEmpty = value;
}
public getLastStartingPosition(): DocumentPosition {
return this._lastStart;
}
public setLastStartingPosition(position: DocumentPosition): void {
this._lastStart = position;
}
public moveEndBackwards(delta: number): void {
this._end = this._end.backwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public trim(): void {
if (this.text.trim() === "") {
this._start = this._end;
return;
}
this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
}
public expandBackwardsWhile(predicate: Predicate): void {
this._start = this._start.backwardsWhile(this.model, predicate);
}
public expandForwardsWhile(predicate: Predicate): void {
this._end = this._end.forwardsWhile(this.model, predicate);
}
public get text(): string {
let text = "";
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const t = part.text.substring(startIdx, endIdx);
text = text + t;
});
return text;
}
/**
* Splits the model at the range boundaries and replaces with the given parts.
* Should be run inside a `model.transform()` callback.
* @param {Part[]} parts the parts to replace the range with
* @return {Number} the net amount of characters added, can be negative.
*/
public replace(parts: Part[]): number {
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
let oldLength = 0;
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
oldLength += endIdx - startIdx;
});
this.model.replaceRange(this._start, this._end, parts);
return newLength - oldLength;
}
/**
* 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.
*/
public get parts(): Part[] {
const parts: Part[] = [];
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const serializedPart = part.serialize();
serializedPart.text = part.text.substring(startIdx, endIdx);
const newPart = this.model.partCreator.deserializePart(serializedPart);
if (newPart) parts.push(newPart);
});
return parts;
}
public get length(): number {
let len = 0;
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
len += endIdx - startIdx;
});
return len;
}
public get start(): DocumentPosition {
return this._start;
}
public get end(): DocumentPosition {
return this._end;
}
}
+180
View File
@@ -0,0 +1,180 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Part, Type } from "./parts";
import type EditorModel from "./model";
export function needsCaretNodeBefore(part: Part, prevPart?: Part): boolean {
const isFirst = !prevPart || prevPart.type === Type.Newline;
return !part.acceptsCaret && (isFirst || !prevPart.acceptsCaret);
}
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean): boolean {
return !part.acceptsCaret && isLastOfLine;
}
function insertAfter(node: ChildNode, nodeToInsert: ChildNode): void {
const next = node.nextSibling;
if (next) {
node.parentElement!.insertBefore(nodeToInsert, next);
} else {
node.parentElement!.appendChild(nodeToInsert);
}
}
// Use a BOM marker for caret nodes.
// On a first test, they seem to be filtered out when copying text out of the editor,
// but this could be platform dependent.
// As a precautionary measure, I chose the character that slate also uses.
export const CARET_NODE_CHAR = "\ufeff";
// a caret node is a node that allows the caret to be placed
// where otherwise it wouldn't be possible
// (e.g. next to a pill span without adjacent text node)
function createCaretNode(): HTMLElement {
const span = document.createElement("span");
span.className = "caretNode";
span.appendChild(document.createTextNode(CARET_NODE_CHAR));
return span;
}
function updateCaretNode(node: ChildNode): void {
// ensure the caret node contains only a zero-width space
if (node.textContent !== CARET_NODE_CHAR) {
node.textContent = CARET_NODE_CHAR;
}
}
export function isCaretNode(node?: Node | null): node is HTMLElement {
return !!node && node instanceof HTMLElement && node.tagName === "SPAN" && node.className === "caretNode";
}
function removeNextSiblings(node: ChildNode | null): void {
if (!node) {
return;
}
node = node.nextSibling;
while (node) {
const removeNode = node;
node = node.nextSibling;
removeNode.remove();
}
}
function removeChildren(parent: HTMLElement): void {
const firstChild = parent.firstChild;
if (firstChild) {
removeNextSiblings(firstChild);
firstChild.remove();
}
}
function reconcileLine(lineContainer: ChildNode, parts: Part[]): void {
let currentNode: ChildNode | null = null;
let prevPart: Part | undefined;
const lastPart = parts[parts.length - 1];
for (const part of parts) {
const isFirst = !prevPart;
currentNode = isFirst ? lineContainer.firstChild : currentNode!.nextSibling;
if (needsCaretNodeBefore(part, prevPart)) {
if (isCaretNode(currentNode as Element)) {
updateCaretNode(currentNode!);
currentNode = currentNode!.nextSibling;
} else {
lineContainer.insertBefore(createCaretNode(), currentNode);
}
}
// remove nodes until matching current part
while (currentNode && !part.canUpdateDOMNode(currentNode)) {
const nextNode = currentNode.nextSibling;
lineContainer.removeChild(currentNode);
currentNode = nextNode;
}
// update or insert node for current part
if (currentNode && part) {
part.updateDOMNode(currentNode);
} else if (part) {
currentNode = part.toDOMNode() as ChildNode;
// hooks up nextSibling for next iteration
lineContainer.appendChild(currentNode);
}
if (needsCaretNodeAfter(part, part === lastPart)) {
if (isCaretNode(currentNode?.nextSibling as Element)) {
currentNode = currentNode!.nextSibling;
updateCaretNode(currentNode as HTMLElement);
} else {
const caretNode = createCaretNode();
insertAfter(currentNode as HTMLElement, caretNode);
currentNode = caretNode;
}
}
prevPart = part;
}
removeNextSiblings(currentNode);
}
function reconcileEmptyLine(lineContainer: HTMLElement): void {
// empty div needs to have a BR in it to give it height
let foundBR = false;
let partNode = lineContainer.firstChild;
while (partNode) {
const nextNode = partNode.nextSibling;
if (!foundBR && (partNode as HTMLElement).tagName === "BR") {
foundBR = true;
} else {
partNode.remove();
}
partNode = nextNode;
}
if (!foundBR) {
lineContainer.appendChild(document.createElement("br"));
}
}
export function renderModel(editor: HTMLDivElement, model: EditorModel): void {
const lines = model.parts.reduce<Part[][]>(
(linesArr, part) => {
if (part.type === Type.Newline) {
linesArr.push([]);
} else {
const lastLine = linesArr[linesArr.length - 1];
lastLine.push(part);
}
return linesArr;
},
[[]],
);
lines.forEach((parts, i) => {
// find first (and remove anything else) div without className
// (as browsers insert these in contenteditable) line container
let lineContainer = editor.childNodes[i];
while (lineContainer && ((<Element>lineContainer).tagName !== "DIV" || !!(<Element>lineContainer).className)) {
editor.removeChild(lineContainer);
lineContainer = editor.childNodes[i];
}
if (!lineContainer) {
lineContainer = document.createElement("div");
editor.appendChild(lineContainer);
}
if (parts.length) {
reconcileLine(lineContainer, parts);
} else {
reconcileEmptyLine(lineContainer as HTMLElement);
}
});
if (lines.length) {
removeNextSiblings(editor.children[lines.length - 1]);
} else {
removeChildren(editor);
}
}
+228
View File
@@ -0,0 +1,228 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { encode } from "html-entities";
import escapeHtml from "escape-html";
import Markdown from "../Markdown";
import { makeGenericPermalink } from "../utils/permalinks/Permalinks";
import type EditorModel from "./model";
import SettingsStore from "../settings/SettingsStore";
import SdkConfig from "../SdkConfig";
import { Type } from "./parts";
export function mdSerialize(model: EditorModel): string {
return model.parts.reduce((html, part) => {
switch (part.type) {
case Type.Newline:
return html + "\n";
case Type.Plain:
case Type.Emoji:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return html + part.text;
case Type.RoomPill: {
const url = makeGenericPermalink(part.resourceId, true);
// Escape square brackets and backslashes
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
const title = part.resourceId.replace(/[[\\\]]/g, (c) => "\\" + c);
return html + `[${title}](${url})`;
}
case Type.UserPill: {
const url = makeGenericPermalink(part.resourceId, true);
// Escape square brackets and backslashes; convert newlines to HTML
const title = part.text.replace(/[[\\\]]/g, (c) => "\\" + c).replace(/\n/g, "<br>");
return html + `[${title}](${url})`;
}
}
}, "");
}
interface ISerializeOpts {
forceHTML?: boolean;
useMarkdown?: boolean;
}
export function htmlSerializeIfNeeded(
model: EditorModel,
{ forceHTML = false, useMarkdown = true }: ISerializeOpts = {},
): string | undefined {
if (!useMarkdown) {
return escapeHtml(textSerialize(model)).replace(/\n/g, "<br/>");
}
const md = mdSerialize(model);
return htmlSerializeFromMdIfNeeded(md, { forceHTML });
}
export function htmlSerializeFromMdIfNeeded(md: string, { forceHTML = false } = {}): string | undefined {
// copy of raw input to remove unwanted math later
const orig = md;
if (SettingsStore.getValue("feature_latex_maths")) {
const patternNames = ["tex", "latex"] as const;
const patternTypes = ["display", "inline"] as const;
const patternDefaults = {
tex: {
// detect math with tex delimiters, inline: $...$, display $$...$$
// preferably use negative lookbehinds, not supported in all major browsers:
// const displayPattern = "^(?<!\\\\)\\$\\$(?![ \\t])(([^$]|\\\\\\$)+?)\\$\\$$";
// const inlinePattern = "(?:^|\\s)(?<!\\\\)\\$(?!\\s)(([^$]|\\\\\\$)+?)(?<!\\\\|\\s)\\$";
// conditions for display math detection $$...$$:
// - pattern starts and ends on a new line
// - left delimiter ($$) is not escaped by backslash
display: "(^)\\$\\$(([^$]|\\\\\\$)+?)\\$\\$$",
// conditions for inline math detection $...$:
// - pattern starts at beginning of line, follows whitespace character or punctuation
// - pattern is on a single line
// - left and right delimiters ($) are not escaped by backslashes
// - left delimiter is not followed by whitespace character
// - right delimiter is not prefixed with whitespace character
inline: "(^|\\s|[.,!?:;])(?!\\\\)\\$(?!\\s)(([^$\\n]|\\\\\\$)*([^\\\\\\s\\$]|\\\\\\$)(?:\\\\\\$)?)\\$",
},
latex: {
// detect math with latex delimiters, inline: \(...\), display \[...\]
// conditions for display math detection \[...\]:
// - pattern starts and ends on a new line
// - pattern is not empty
display: "(^)\\\\\\[(?!\\\\\\])(.*?)\\\\\\]$",
// conditions for inline math detection \(...\):
// - pattern starts at beginning of line or is not prefixed with backslash
// - pattern is not empty
inline: "(^|[^\\\\])\\\\\\((?!\\\\\\))(.*?)\\\\\\)",
},
};
patternNames.forEach(function (patternName) {
patternTypes.forEach(function (patternType) {
// get the regex replace pattern from config or use the default
const pattern =
SdkConfig.get("latex_maths_delims")?.[patternType]?.["pattern"]?.[patternName] ||
patternDefaults[patternName][patternType];
md = md.replace(RegExp(pattern, "gms"), function (m, p1, p2) {
const p2e = encode(p2);
switch (patternType) {
case "display":
return `${p1}<div data-mx-maths="${p2e}">\n\n</div>\n\n`;
case "inline":
return `${p1}<span data-mx-maths="${p2e}"></span>`;
}
});
});
});
// make sure div tags always start on a new line, otherwise it will confuse the markdown parser
md = md.replace(/(.)<div/g, function (m, p1) {
return `${p1}\n<div`;
});
}
const parser = new Markdown(md);
if (!parser.isPlainText() || forceHTML) {
// feed Markdown output to HTML parser
const phtml = new DOMParser().parseFromString(parser.toHTML(), "text/html");
if (SettingsStore.getValue("feature_latex_maths")) {
// original Markdown without LaTeX replacements
const parserOrig = new Markdown(orig);
const phtmlOrig = new DOMParser().parseFromString(parserOrig.toHTML(), "text/html");
// since maths delimiters are handled before Markdown,
// code blocks could contain mangled content.
// replace code blocks with original content
[...phtmlOrig.getElementsByTagName("code")].forEach((e, i) => {
phtml.getElementsByTagName("code").item(i)!.textContent = e.textContent;
});
// add fallback output for latex math, which should not be interpreted as markdown
[...phtml.querySelectorAll("div, span")].forEach((e, i) => {
const tex = e.getAttribute("data-mx-maths");
if (tex) {
e.innerHTML = `<code>${tex}</code>`;
}
});
}
return phtml.body.innerHTML;
}
// ensure removal of escape backslashes in non-Markdown messages
if (md.indexOf("\\") > -1) {
return parser.toPlaintext();
}
}
export function textSerialize(model: EditorModel): string {
return model.parts.reduce((text, part) => {
switch (part.type) {
case Type.Newline:
return text + "\n";
case Type.Plain:
case Type.Emoji:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return text + part.text;
case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
return text + `${part.resourceId}`;
case Type.UserPill:
return text + `${part.text}`;
}
}, "");
}
export function containsEmote(model: EditorModel): boolean {
const hasCommand = startsWith(model, "/me ", false);
const hasArgument = model.parts[0]?.text?.length > 4 || model.parts.length > 1;
return hasCommand && hasArgument;
}
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true): boolean {
const firstPart = model.parts[0];
// part type will be "plain" while editing,
// and "command" while composing a message.
let text = firstPart?.text || "";
if (!caseSensitive) {
prefix = prefix.toLowerCase();
text = text.toLowerCase();
}
return firstPart && (firstPart.type === Type.Plain || firstPart.type === Type.Command) && text.startsWith(prefix);
}
export function stripEmoteCommand(model: EditorModel): EditorModel {
// trim "/me "
return stripPrefix(model, "/me ");
}
export function stripPrefix(model: EditorModel, prefix: string): EditorModel {
model = model.clone();
model.removeText({ index: 0, offset: 0 }, prefix.length);
return model;
}
export function unescapeMessage(model: EditorModel): EditorModel {
const { parts } = model;
if (parts.length) {
const firstPart = parts[0];
// only unescape \/ to / at start of editor
if (firstPart.type === Type.Plain && firstPart.text.startsWith("\\/")) {
model = model.clone();
model.removeText({ index: 0, offset: 0 }, 1);
}
}
return model;
}