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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(``);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user