Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/a11y/composer-list-autocomplete
Conflicts: src/components/structures/LoggedInView.tsx src/components/views/rooms/BasicMessageComposer.tsx src/editor/autocomplete.ts
This commit is contained in:
@@ -68,9 +68,11 @@ export default class AutocompleteWrapperModel {
|
||||
this.updateCallback({close: true});
|
||||
}
|
||||
|
||||
public async onTab(e: KeyboardEvent) {
|
||||
/**
|
||||
* If there is no current autocompletion, start one and move to the first selection.
|
||||
*/
|
||||
public async startSelection() {
|
||||
const acComponent = this.getAutocompleterComponent();
|
||||
|
||||
if (acComponent.countCompletions() === 0) {
|
||||
// Force completions to show for the text currently entered
|
||||
await acComponent.forceComplete();
|
||||
@@ -80,11 +82,11 @@ export default class AutocompleteWrapperModel {
|
||||
}
|
||||
}
|
||||
|
||||
public onUpArrow(e: KeyboardEvent) {
|
||||
public selectPreviousSelection() {
|
||||
this.getAutocompleterComponent().moveSelection(-1);
|
||||
}
|
||||
|
||||
public onDownArrow(e: KeyboardEvent) {
|
||||
public selectNextSelection() {
|
||||
this.getAutocompleterComponent().moveSelection(+1);
|
||||
}
|
||||
|
||||
@@ -124,10 +126,8 @@ export default class AutocompleteWrapperModel {
|
||||
case "at-room":
|
||||
return [this.partCreator.atRoomPill(completionId), this.partCreator.plain(completion.suffix)];
|
||||
case "user":
|
||||
// not using suffix here, because we also need to calculate
|
||||
// the suffix when clicking a display name to insert a mention,
|
||||
// which happens in createMentionParts
|
||||
return this.partCreator.createMentionParts(this.partIndex, text, completionId);
|
||||
// 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)];
|
||||
|
||||
@@ -60,6 +60,11 @@ function parseLink(a: HTMLAnchorElement, partCreator: PartCreator) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseImage(img: HTMLImageElement, partCreator: PartCreator) {
|
||||
const { src } = img;
|
||||
return partCreator.plain(`![${img.alt.replace(/[[\\\]]/g, c => "\\" + c)}](${src})`);
|
||||
}
|
||||
|
||||
function parseCodeBlock(n: HTMLElement, partCreator: PartCreator) {
|
||||
const parts = [];
|
||||
let language = "";
|
||||
@@ -102,6 +107,8 @@ function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLEl
|
||||
return parseHeader(n, partCreator);
|
||||
case "A":
|
||||
return parseLink(<HTMLAnchorElement>n, partCreator);
|
||||
case "IMG":
|
||||
return parseImage(<HTMLImageElement>n, partCreator);
|
||||
case "BR":
|
||||
return partCreator.newline();
|
||||
case "EM":
|
||||
@@ -136,11 +143,11 @@ function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLEl
|
||||
// math nodes are translated back into delimited latex strings
|
||||
if (n.hasAttribute("data-mx-maths")) {
|
||||
const delimLeft = (n.nodeName == "SPAN") ?
|
||||
(SdkConfig.get()['latex_maths_delims'] || {})['inline_left'] || "$" :
|
||||
(SdkConfig.get()['latex_maths_delims'] || {})['display_left'] || "$$";
|
||||
((SdkConfig.get()['latex_maths_delims'] || {})['inline'] || {})['left'] || "\\(" :
|
||||
((SdkConfig.get()['latex_maths_delims'] || {})['display'] || {})['left'] || "\\[";
|
||||
const delimRight = (n.nodeName == "SPAN") ?
|
||||
(SdkConfig.get()['latex_maths_delims'] || {})['inline_right'] || "$" :
|
||||
(SdkConfig.get()['latex_maths_delims'] || {})['display_right'] || "$$";
|
||||
((SdkConfig.get()['latex_maths_delims'] || {})['inline'] || {})['right'] || "\\)" :
|
||||
((SdkConfig.get()['latex_maths_delims'] || {})['display'] || {})['right'] || "\\]";
|
||||
const tex = n.getAttribute("data-mx-maths");
|
||||
return partCreator.plain(delimLeft + tex + delimRight);
|
||||
} else if (!checkDescendInto(n)) {
|
||||
|
||||
@@ -158,7 +158,7 @@ export default class EditorModel {
|
||||
}
|
||||
}
|
||||
|
||||
reset(serializedParts: SerializedPart[], caret: Caret, inputType: string) {
|
||||
reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string) {
|
||||
this._parts = serializedParts.map(p => this._partCreator.deserializePart(p));
|
||||
if (!caret) {
|
||||
caret = this.getPositionAtEnd();
|
||||
|
||||
@@ -34,7 +34,7 @@ interface ISerializedPart {
|
||||
interface ISerializedPillPart {
|
||||
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
|
||||
text: string;
|
||||
resourceId: string;
|
||||
resourceId?: string;
|
||||
}
|
||||
|
||||
export type SerializedPart = ISerializedPart | ISerializedPillPart;
|
||||
@@ -189,7 +189,13 @@ abstract class PlainBasePart extends BasePart {
|
||||
if (chr !== "@" && chr !== "#" && chr !== ":" && chr !== "+") {
|
||||
return true;
|
||||
}
|
||||
// only split if the previous character is a space
|
||||
|
||||
// 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 if it is a + and this is a :
|
||||
return this._text[offset - 1] !== " " &&
|
||||
(this._text[offset - 1] !== "+" || chr !== ":");
|
||||
@@ -281,6 +287,14 @@ abstract class PillPart extends BasePart implements IPillPart {
|
||||
}
|
||||
}
|
||||
|
||||
serialize(): ISerializedPillPart {
|
||||
return {
|
||||
type: this.type,
|
||||
text: this.text,
|
||||
resourceId: this.resourceId,
|
||||
};
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return false;
|
||||
}
|
||||
@@ -329,17 +343,13 @@ class NewlinePart extends BasePart implements IBasePart {
|
||||
}
|
||||
|
||||
class RoomPillPart extends PillPart {
|
||||
constructor(displayAlias, private room: Room) {
|
||||
super(displayAlias, displayAlias);
|
||||
constructor(resourceId: string, label: string, private room: Room) {
|
||||
super(resourceId, label);
|
||||
}
|
||||
|
||||
setAvatar(node: HTMLElement) {
|
||||
let initialLetter = "";
|
||||
let avatarUrl = Avatar.avatarUrlForRoom(
|
||||
this.room,
|
||||
16 * window.devicePixelRatio,
|
||||
16 * window.devicePixelRatio,
|
||||
"crop");
|
||||
let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
|
||||
if (!avatarUrl) {
|
||||
initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId);
|
||||
avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId);
|
||||
@@ -357,9 +367,20 @@ class RoomPillPart extends PillPart {
|
||||
}
|
||||
|
||||
class AtRoomPillPart extends RoomPillPart {
|
||||
constructor(text: string, room: Room) {
|
||||
super(text, text, room);
|
||||
}
|
||||
|
||||
get type(): IPillPart["type"] {
|
||||
return Type.AtRoomPill;
|
||||
}
|
||||
|
||||
serialize(): ISerializedPillPart {
|
||||
return {
|
||||
type: this.type,
|
||||
text: this.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class UserPillPart extends PillPart {
|
||||
@@ -373,11 +394,7 @@ class UserPillPart extends PillPart {
|
||||
}
|
||||
const name = this.member.name || this.member.userId;
|
||||
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
|
||||
const avatarUrl = Avatar.avatarUrlForMember(
|
||||
this.member,
|
||||
16 * window.devicePixelRatio,
|
||||
16 * window.devicePixelRatio,
|
||||
"crop");
|
||||
const avatarUrl = Avatar.avatarUrlForMember(this.member, 16, 16, "crop");
|
||||
let initialLetter = "";
|
||||
if (avatarUrl === defaultAvatarUrl) {
|
||||
initialLetter = Avatar.getInitialLetter(name);
|
||||
@@ -392,14 +409,6 @@ class UserPillPart extends PillPart {
|
||||
get className() {
|
||||
return "mx_UserPill mx_Pill";
|
||||
}
|
||||
|
||||
serialize(): ISerializedPillPart {
|
||||
return {
|
||||
type: this.type,
|
||||
text: this.text,
|
||||
resourceId: this.resourceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
|
||||
@@ -493,7 +502,7 @@ export class PartCreator {
|
||||
case Type.PillCandidate:
|
||||
return this.pillCandidate(part.text);
|
||||
case Type.RoomPill:
|
||||
return this.roomPill(part.text);
|
||||
return this.roomPill(part.resourceId);
|
||||
case Type.UserPill:
|
||||
return this.userPill(part.text, part.resourceId);
|
||||
}
|
||||
@@ -521,7 +530,7 @@ export class PartCreator {
|
||||
r.getAltAliases().includes(alias);
|
||||
});
|
||||
}
|
||||
return new RoomPillPart(alias, room);
|
||||
return new RoomPillPart(alias, room ? room.name : alias, room);
|
||||
}
|
||||
|
||||
atRoomPill(text: string) {
|
||||
@@ -533,9 +542,9 @@ export class PartCreator {
|
||||
return new UserPillPart(userId, displayName, member);
|
||||
}
|
||||
|
||||
createMentionParts(partIndex: number, displayName: string, userId: string) {
|
||||
createMentionParts(insertTrailingCharacter: boolean, displayName: string, userId: string) {
|
||||
const pill = this.userPill(displayName, userId);
|
||||
const postfix = this.plain(partIndex === 0 ? ": " : " ");
|
||||
const postfix = this.plain(insertTrailingCharacter ? ": " : " ");
|
||||
return [pill, postfix];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ export function mdSerialize(model: EditorModel) {
|
||||
case "at-room-pill":
|
||||
return html + part.text;
|
||||
case "room-pill":
|
||||
// Here we use the resourceId for compatibility with non-rich text clients
|
||||
// See https://github.com/vector-im/element-web/issues/16660
|
||||
return html +
|
||||
`[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
|
||||
case "user-pill":
|
||||
return html +
|
||||
`[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
|
||||
@@ -43,21 +47,65 @@ export function mdSerialize(model: EditorModel) {
|
||||
|
||||
export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) {
|
||||
let md = mdSerialize(model);
|
||||
// copy of raw input to remove unwanted math later
|
||||
const orig = md;
|
||||
|
||||
if (SettingsStore.getValue("feature_latex_maths")) {
|
||||
const displayPattern = (SdkConfig.get()['latex_maths_delims'] || {})['display_pattern'] ||
|
||||
"\\$\\$(([^$]|\\\\\\$)*)\\$\\$";
|
||||
const inlinePattern = (SdkConfig.get()['latex_maths_delims'] || {})['inline_pattern'] ||
|
||||
"\\$(([^$]|\\\\\\$)*)\\$";
|
||||
const patternNames = ['tex', 'latex'];
|
||||
const patternTypes = ['display', 'inline'];
|
||||
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)\\$";
|
||||
|
||||
md = md.replace(RegExp(displayPattern, "gm"), function(m, p1) {
|
||||
const p1e = AllHtmlEntities.encode(p1);
|
||||
return `<div data-mx-maths="${p1e}">\n\n</div>\n\n`;
|
||||
});
|
||||
// conditions for display math detection $$...$$:
|
||||
// - pattern starts and ends on a new line
|
||||
// - left delimiter ($$) is not escaped by backslash
|
||||
"display": "(^)\\$\\$(([^$]|\\\\\\$)+?)\\$\\$$",
|
||||
|
||||
md = md.replace(RegExp(inlinePattern, "gm"), function(m, p1) {
|
||||
const p1e = AllHtmlEntities.encode(p1);
|
||||
return `<span data-mx-maths="${p1e}"></span>`;
|
||||
// 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 = AllHtmlEntities.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
|
||||
@@ -68,16 +116,38 @@ export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} =
|
||||
const parser = new Markdown(md);
|
||||
if (!parser.isPlainText() || forceHTML) {
|
||||
// feed Markdown output to HTML parser
|
||||
const phtml = cheerio.load(parser.toHTML(),
|
||||
{ _useHtmlParser2: true, decodeEntities: false })
|
||||
|
||||
// add fallback output for latex math, which should not be interpreted as markdown
|
||||
phtml('div, span').each(function(i, e) {
|
||||
const tex = phtml(e).attr('data-mx-maths')
|
||||
if (tex) {
|
||||
phtml(e).html(`<code>${tex}</code>`)
|
||||
}
|
||||
const phtml = cheerio.load(parser.toHTML(), {
|
||||
// @ts-ignore: The `_useHtmlParser2` internal option is the
|
||||
// simplest way to both parse and render using `htmlparser2`.
|
||||
_useHtmlParser2: true,
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
if (SettingsStore.getValue("feature_latex_maths")) {
|
||||
// original Markdown without LaTeX replacements
|
||||
const parserOrig = new Markdown(orig);
|
||||
const phtmlOrig = cheerio.load(parserOrig.toHTML(), {
|
||||
// @ts-ignore: The `_useHtmlParser2` internal option is the
|
||||
// simplest way to both parse and render using `htmlparser2`.
|
||||
_useHtmlParser2: true,
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
// since maths delimiters are handled before Markdown,
|
||||
// code blocks could contain mangled content.
|
||||
// replace code blocks with original content
|
||||
phtmlOrig('code').each(function(i) {
|
||||
phtml('code').eq(i).text(phtmlOrig('code').eq(i).text());
|
||||
});
|
||||
|
||||
// add fallback output for latex math, which should not be interpreted as markdown
|
||||
phtml('div, span').each(function(i, e) {
|
||||
const tex = phtml(e).attr('data-mx-maths')
|
||||
if (tex) {
|
||||
phtml(e).html(`<code>${tex}</code>`)
|
||||
}
|
||||
});
|
||||
}
|
||||
return phtml.html();
|
||||
}
|
||||
// ensure removal of escape backslashes in non-Markdown messages
|
||||
@@ -97,6 +167,9 @@ export function textSerialize(model: EditorModel) {
|
||||
case "at-room-pill":
|
||||
return text + part.text;
|
||||
case "room-pill":
|
||||
// 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 "user-pill":
|
||||
return text + `${part.text}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user