rich formatting works

This commit is contained in:
or-else
2017-08-21 19:45:48 -07:00
parent 0c7549819a
commit fb6b4899cd
2 changed files with 187 additions and 26 deletions
+156 -23
View File
@@ -1,4 +1,4 @@
// Basic parser and formatter for very simple rich text. Mostly targeted at
// Basic parser and formatter for very simple text markup. Mostly targeted at
// mobile use cases similar to Telegram and WhatsApp.
//
// Supports:
@@ -8,9 +8,9 @@
// `abc` -> <tt>abc</tt>
// Nested frmatting is supported, e.g. *abc _def_* -> <b>abc <i>def</i></b>
//
// URLs, @mentions, and #hashtags are extracted.
// URLs, @mentions, and #hashtags are extracted and converted into links.
//
// JSON data representation is similar to Draft.js raw formatting.
// JSON data representation is similar (easily converted) to Draft.js raw formatting.
/*
Text:
@@ -185,26 +185,38 @@ Sample JSON representation of the text above:
re: /\b#(\w\w+)/g}
];
// Formatting HTML tags
var STYLE_DECOR = {
ST: ['<b>', '</b>'],
EM: ['<i>', '</i>'],
DL: ['<del>', '</del>'],
CO: ['<tt>', '</tt>']
// HTML tag name suggestions
var TAG_NAMES = {
ST: 'b',
EM: 'i',
DL: 'del',
CO: 'tt',
BR: 'br',
LN: 'a',
MN: 'a',
HT: 'a'
};
var ENTITY_DECOR = {
// Helpers for converting Drafty to HTML.
var DECORATORS = {
ST: { open: function() { return '<b>'; }, close: function() { return '</b>'; }},
EM: { open: function() { return '<i>'; }, close: function() { return '</i>'}},
DL: { open: function() { return '<del>'; }, close: function() { return '</del>'}},
CO: { open: function() { return '<tt>'; }, close: function() { return '</tt>'}},
LN: {
open: function(data) { return '<a href="' + data.url + '">'; },
close: function(data) { return '</a>'; }
close: function(data) { return '</a>'; },
props: function(data) { return { href: data.url, target: "_blank" }; },
},
MN: {
open: function(data) { return '<a href="#' + data.val + '">'; },
close: function(data) { return '</a>'; }
close: function(data) { return '</a>'; },
props: function(data) { return { name: data.val }; },
},
HT: {
open: function(data) { return '<a href="#' + data.val + '">'; },
close: function(data) { return '</a>'; }
close: function(data) { return '</a>'; },
props: function(data) { return { name: data.val }; },
}
};
@@ -252,6 +264,40 @@ Sample JSON representation of the text above:
return chunks;
}
// Same as chunkify but used for formatting.
function forEach(line, start, end, spans, formatter, context) {
// Add un-styled range before the styled span starts.
// Process ranges calling formatter for each range.
var result = [];
for (var i = 0; i < spans.length; i++) {
var span = spans[i];
// Add un-styled range before the styled span starts.
if (start < span.at) {
result.push(formatter.call(context, null, undefined, line.slice(start, span.at)));
start = span.at;
}
// Get all spans which are within current span.
var subspans = [];
for (var si = i + 1; si < spans.length && spans[si].at < span.at + span.len; si++) {
subspans.push(spans[si]);
i = si;
}
result.push(formatter.call(context, span.tp, span.data,
forEach(line, start, span.at + span.len, subspans, formatter, context)));
start = span.at + span.len;
}
// Add the last unformatted range.
if (start < end) {
result.push(formatter.call(context, null, undefined, line.slice(start)));
}
return result;
}
// Detect starts and ends of formatting spans. Unformatted spans are
// ignored at this stage.
function spannify(original, re_start, re_end, type) {
@@ -470,9 +516,16 @@ Sample JSON representation of the text above:
},
/**
* Given structured representation of rich text, convert it to HTML.
* Given the structured representation of rich text, convert it to HTML.
* No attempt is made to strip pre-existing html markup.
* This is potentially unsafe because block.txt may contain malicious
* markup.
*
* @param {drafy} content - structured representation of rich text.
*
* @return HTML-representation of content
*/
toHTML: function(content) {
unsafeToHTML: function(content) {
var {blocks} = content;
var {refs} = content;
@@ -483,8 +536,8 @@ Sample JSON representation of the text above:
if (blocks[i].fmt) {
for (var j in blocks[i].fmt) {
var range = blocks[i].fmt[j];
markup.push({idx: range.at, what: STYLE_DECOR[range.tp][0]});
markup.push({idx: range.at + range.len, what: STYLE_DECOR[range.tp][1]});
markup.push({idx: range.at, what: DECORATORS[range.tp].open()});
markup.push({idx: range.at + range.len, what: DECORATORS[range.tp].close()});
}
}
@@ -492,8 +545,8 @@ Sample JSON representation of the text above:
for (var j in blocks[i].ent) {
var range = blocks[i].ent[j];
var entity = refs[range.key];
markup.push({idx: range.at, what: ENTITY_DECOR[entity.tp].open(entity.data)});
markup.push({idx: range.at + range.len, what: ENTITY_DECOR[entity.tp].close(entity.data)});
markup.push({idx: range.at, what: DECORATORS[entity.tp].open(entity.data)});
markup.push({idx: range.at + range.len, what: DECORATORS[entity.tp].close(entity.data)});
}
}
@@ -511,33 +564,113 @@ Sample JSON representation of the text above:
return text.join("<br/>");
},
/*
/**
* Transform using custom formatting.
*
* @param {Drafty} content - content to transform.
* @param {function} formatter - callback which transforms individual elements
* @param {Object} context - context provided to formatter as 'this'.
*
* @return {Object} context
*/
format: function(content, formatter, context) {
var {blocks} = content;
var {refs} = content;
var result = [];
// Process blocks one by one
for (var i=0;i < blocks.length; i++) {
var block = blocks[i];
var spans = [];
// Merge markup ranges and sort in ascending order.
if (block.fmt) {
spans = spans.concat(block.fmt);
}
if (block.ent) {
spans = spans.concat(block.ent);
}
// Soft spans first by start index (asc) then by length (desc).
spans.sort(function(a, b) {
if (a.at - b.at == 0) {
return b.len - a.len; // longer one comes first (<0)
}
return a.at - b.at;
});
// Denormalize entities.
spans.map(function(s) {
if (s.key + 1 > 0) {
s.data = refs[s.key].data;
s.tp = refs[s.key].tp;
}
});
result = result.concat(forEach(block.txt, 0, block.txt.length - 1, spans, formatter, context));
if (i != blocks.length - 1) {
// Add line break
result.push(formatter.call(context, "BR"));
}
}
return result;
},
/**
* Given structured representation of rich text, convert it to plain text.
* The only structure preserved is line breaks as \n.
*
* @param {drafty} content - content to convert to plain text.
*/
toPlainText: function(content) {
var {blocks} = content;
var text = blocks[0].txt;
for (var i = 1; i < blocks.length; i++) {
text += "\n" + blocks[i].txt;
text += (lineBreak ? lineBreak.call(context) : "\n") + blocks[i].txt;
}
return text;
},
/**
* Returns true if content has no markup and no entities.
*
* @param {drafty} content - content to check for presence of markup.
* @param {boolean} noLineBreaks - true to treat line breaks as markup, false
* to treat them as plain text.
* @returns true is content is plain text, false otherwise.
*/
isPlain: function(content) {
isPlainText: function(content, noLineBreaks) {
if (content.refs) {
return false;
}
var {blocks} = content;
if (noLineBreaks && blocks.length > 1) {
return false;
}
for (var i in blocks) {
if (blocks[i].fmt) {
return false;
}
}
return true;
},
tagName: function(style) {
return TAG_NAMES[style];
},
attrValue: function(style, data) {
if (data && DECORATORS[style]) {
return DECORATORS[style].props(data);
}
return undefined;
}
};
@@ -547,4 +680,4 @@ Sample JSON representation of the text above:
if (typeof(environment.Drafty) === 'undefined') {
environment.Drafty = Drafty();
}
})(this); // 'this' will be window object for the browsers.
})(this); // 'this' will be a window object for the browsers.
+31 -3
View File
@@ -2027,12 +2027,38 @@
var Message = React.createClass({
render: function() {
var elementKey = 0;
var formatter = function(style, data, values) {
elementKey += 1;
var el = Drafty.tagName(style);
if (el) {
var attr = Drafty.attrValue(style, data) || {};
attr.key = elementKey;
return React.createElement(el, attr, values);
} else {
return values;
}
};
var sideClass = this.props.sequence + " " + (this.props.response ? "left" : "right");
var bubbleClass = (this.props.sequence === "single" || this.props.sequence === "last") ?
"bubble tip" : "bubble";
var avatar = this.props.userAvatar || true;
var fullDisplay = (this.props.userFrom && this.props.response &&
(this.props.sequence === "single" || this.props.sequence === "last"));
var content = this.props.content;
if (this.props.mimeType === "text/x-drafty+json") {
content = React.createElement('span', null, Drafty.format(content, formatter));
} else {
// Handle line breaks in plain text.
// Regex capure group keep line breaks in the array.
content = content.split(/(\n)/g).map(function(line, idx) {
return line.match("\n") ? <br key={idx} /> : line;
});
}
return (
<li className={sideClass}>
{this.props.userFrom && this.props.response ?
@@ -2045,7 +2071,7 @@
null}
<div>
<div className={bubbleClass}>
<p>{this.props.content}
<p>{content}
<Message.ReceivedMarker response={this.props.response}
timestamp={this.props.timestamp} received={this.props.received} />
</p>
@@ -2239,8 +2265,10 @@
} else {
chatBoxClass="chat-box";
}
messageNodes.push(
<Message content={msg.content} timestamp={msg.ts} response={isReply}
<Message content={msg.content} mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} response={isReply}
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
sequence={sequence} received={msgReceived} key={msg.seq} />
);
@@ -2622,7 +2650,7 @@
var topic = Tinode.getTopic(this.state.topicSelected);
// Check if the message is formatted.
var dft = Drafty.parse(msg);
if (Drafty.isPlain(dft)) {
if (Drafty.isPlainText(dft)) {
// The message is simple text. Sned if as text/plain.
topic.publish(msg);
} else {