Merge pull request #3013 from matrix-org/bwindels/editor-formatting

Message editing: preserve and re-apply formatting
This commit is contained in:
Bruno Windels 2019-05-24 07:34:05 +00:00 committed by GitHub
commit 3468cef654
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 108 additions and 26 deletions

View file

@ -22,7 +22,7 @@ import dis from '../../../dispatcher';
import EditorModel from '../../../editor/model'; import EditorModel from '../../../editor/model';
import {setCaretPosition} from '../../../editor/caret'; import {setCaretPosition} from '../../../editor/caret';
import {getCaretOffsetAndText} from '../../../editor/dom'; import {getCaretOffsetAndText} from '../../../editor/dom';
import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize'; import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize';
import {parseEvent} from '../../../editor/deserialize'; import {parseEvent} from '../../../editor/deserialize';
import Autocomplete from '../rooms/Autocomplete'; import Autocomplete from '../rooms/Autocomplete';
import {PartCreator} from '../../../editor/parts'; import {PartCreator} from '../../../editor/parts';
@ -128,9 +128,10 @@ export default class MessageEditor extends React.Component {
msgtype: newContent.msgtype, msgtype: newContent.msgtype,
body: ` * ${newContent.body}`, body: ` * ${newContent.body}`,
}; };
if (requiresHtml(this.model)) { const formattedBody = htmlSerializeIfNeeded(this.model);
if (formattedBody) {
newContent.format = "org.matrix.custom.html"; newContent.format = "org.matrix.custom.html";
newContent.formatted_body = htmlSerialize(this.model); newContent.formatted_body = formattedBody;
contentBody.format = newContent.format; contentBody.format = newContent.format;
contentBody.formatted_body = ` * ${newContent.formatted_body}`; contentBody.formatted_body = ` * ${newContent.formatted_body}`;
} }

View file

@ -18,40 +18,111 @@ limitations under the License.
import { MATRIXTO_URL_PATTERN } from '../linkify-matrix'; import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts"; import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
function parseLink(a, parts, room) {
const {href} = a;
const pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID
const prefix = pillMatch[2]; // The first character of prefix
switch (prefix) {
case "@":
parts.push(new UserPillPart(
resourceId,
a.textContent,
room.getMember(resourceId),
));
break;
case "#":
parts.push(new RoomPillPart(resourceId));
break;
default: {
if (href === a.textContent) {
parts.push(new PlainPart(a.textContent));
} else {
parts.push(new PlainPart(`[${a.textContent}](${href})`));
}
break;
}
}
}
function parseHtmlMessage(html, room) { function parseHtmlMessage(html, room) {
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
// no nodes from parsing here should be inserted in the document, // no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then. // as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine // we're only taking text, so that is fine
const nodes = Array.from(new DOMParser().parseFromString(html, "text/html").body.childNodes); const root = new DOMParser().parseFromString(html, "text/html").body;
const parts = nodes.map(n => { let n = root.firstChild;
const parts = [];
let isFirstNode = true;
while (n && n !== root) {
switch (n.nodeType) { switch (n.nodeType) {
case Node.TEXT_NODE: case Node.TEXT_NODE:
return new PlainPart(n.nodeValue); // the plainpart doesn't accept \n and will cause
// a newlinepart to be created.
if (n.nodeValue !== "\n") {
parts.push(new PlainPart(n.nodeValue));
}
break;
case Node.ELEMENT_NODE: case Node.ELEMENT_NODE:
switch (n.nodeName) { switch (n.nodeName) {
case "MX-REPLY": case "DIV":
return null; case "P": {
case "A": { // block element should cause line break if not first
const {href} = n; if (!isFirstNode) {
const pillMatch = REGEX_MATRIXTO.exec(href) || []; parts.push(new NewlinePart("\n"));
const resourceId = pillMatch[1]; // The room/user ID }
const prefix = pillMatch[2]; // The first character of prefix // decend into paragraph or div
switch (prefix) { if (n.firstChild) {
case "@": return new UserPillPart(resourceId, n.textContent, room.getMember(resourceId)); n = n.firstChild;
case "#": return new RoomPillPart(resourceId); continue;
default: return new PlainPart(n.textContent); } else {
break;
} }
} }
case "A": {
parseLink(n, parts, room);
break;
}
case "BR": case "BR":
return new NewlinePart("\n"); parts.push(new NewlinePart("\n"));
break;
case "EM":
parts.push(new PlainPart(`*${n.textContent}*`));
break;
case "STRONG":
parts.push(new PlainPart(`**${n.textContent}**`));
break;
case "PRE": {
// block element should cause line break if not first
if (!isFirstNode) {
parts.push(new NewlinePart("\n"));
}
const preLines = `\`\`\`\n${n.textContent}\`\`\``.split("\n");
preLines.forEach((l, i) => {
parts.push(new PlainPart(l));
if (i < preLines.length - 1) {
parts.push(new NewlinePart("\n"));
}
});
break;
}
case "CODE":
parts.push(new PlainPart(`\`${n.textContent}\``));
break;
default: default:
return new PlainPart(n.textContent); parts.push(new PlainPart(n.textContent));
break;
} }
default: break;
return null;
} }
}).filter(p => !!p); // go up if we can't go next
if (!n.nextSibling) {
n = n.parentElement;
}
n = n.nextSibling;
isFirstNode = false;
}
return parts; return parts;
} }

View file

@ -15,21 +15,31 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export function htmlSerialize(model) { import Markdown from '../Markdown';
export function mdSerialize(model) {
return model.parts.reduce((html, part) => { return model.parts.reduce((html, part) => {
switch (part.type) { switch (part.type) {
case "newline": case "newline":
return html + "<br />"; return html + "\n";
case "plain": case "plain":
case "pill-candidate": case "pill-candidate":
return html + part.text; return html + part.text;
case "room-pill": case "room-pill":
case "user-pill": case "user-pill":
return html + `<a href="https://matrix.to/#/${part.resourceId}">${part.text}</a>`; return html + `[${part.text}](https://matrix.to/#/${part.resourceId})`;
} }
}, ""); }, "");
} }
export function htmlSerializeIfNeeded(model) {
const md = mdSerialize(model);
const parser = new Markdown(md);
if (!parser.isPlainText()) {
return parser.toHTML();
}
}
export function textSerialize(model) { export function textSerialize(model) {
return model.parts.reduce((text, part) => { return model.parts.reduce((text, part) => {
switch (part.type) { switch (part.type) {