rich formatting works
This commit is contained in:
@@ -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.
|
// mobile use cases similar to Telegram and WhatsApp.
|
||||||
//
|
//
|
||||||
// Supports:
|
// Supports:
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
// `abc` -> <tt>abc</tt>
|
// `abc` -> <tt>abc</tt>
|
||||||
// Nested frmatting is supported, e.g. *abc _def_* -> <b>abc <i>def</i></b>
|
// 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:
|
Text:
|
||||||
@@ -185,26 +185,38 @@ Sample JSON representation of the text above:
|
|||||||
re: /\b#(\w\w+)/g}
|
re: /\b#(\w\w+)/g}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Formatting HTML tags
|
// HTML tag name suggestions
|
||||||
var STYLE_DECOR = {
|
var TAG_NAMES = {
|
||||||
ST: ['<b>', '</b>'],
|
ST: 'b',
|
||||||
EM: ['<i>', '</i>'],
|
EM: 'i',
|
||||||
DL: ['<del>', '</del>'],
|
DL: 'del',
|
||||||
CO: ['<tt>', '</tt>']
|
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: {
|
LN: {
|
||||||
open: function(data) { return '<a href="' + data.url + '">'; },
|
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: {
|
MN: {
|
||||||
open: function(data) { return '<a href="#' + data.val + '">'; },
|
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: {
|
HT: {
|
||||||
open: function(data) { return '<a href="#' + data.val + '">'; },
|
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;
|
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
|
// Detect starts and ends of formatting spans. Unformatted spans are
|
||||||
// ignored at this stage.
|
// ignored at this stage.
|
||||||
function spannify(original, re_start, re_end, type) {
|
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 {blocks} = content;
|
||||||
var {refs} = content;
|
var {refs} = content;
|
||||||
|
|
||||||
@@ -483,8 +536,8 @@ Sample JSON representation of the text above:
|
|||||||
if (blocks[i].fmt) {
|
if (blocks[i].fmt) {
|
||||||
for (var j in blocks[i].fmt) {
|
for (var j in blocks[i].fmt) {
|
||||||
var range = blocks[i].fmt[j];
|
var range = blocks[i].fmt[j];
|
||||||
markup.push({idx: range.at, what: STYLE_DECOR[range.tp][0]});
|
markup.push({idx: range.at, what: DECORATORS[range.tp].open()});
|
||||||
markup.push({idx: range.at + range.len, what: STYLE_DECOR[range.tp][1]});
|
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) {
|
for (var j in blocks[i].ent) {
|
||||||
var range = blocks[i].ent[j];
|
var range = blocks[i].ent[j];
|
||||||
var entity = refs[range.key];
|
var entity = refs[range.key];
|
||||||
markup.push({idx: range.at, what: ENTITY_DECOR[entity.tp].open(entity.data)});
|
markup.push({idx: range.at, what: DECORATORS[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 + range.len, what: DECORATORS[entity.tp].close(entity.data)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,33 +564,113 @@ Sample JSON representation of the text above:
|
|||||||
return text.join("<br/>");
|
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.
|
* Given structured representation of rich text, convert it to plain text.
|
||||||
* The only structure preserved is line breaks as \n.
|
* The only structure preserved is line breaks as \n.
|
||||||
|
*
|
||||||
|
* @param {drafty} content - content to convert to plain text.
|
||||||
*/
|
*/
|
||||||
toPlainText: function(content) {
|
toPlainText: function(content) {
|
||||||
var {blocks} = content;
|
var {blocks} = content;
|
||||||
var text = blocks[0].txt;
|
var text = blocks[0].txt;
|
||||||
for (var i = 1; i < blocks.length; i++) {
|
for (var i = 1; i < blocks.length; i++) {
|
||||||
text += "\n" + blocks[i].txt;
|
text += (lineBreak ? lineBreak.call(context) : "\n") + blocks[i].txt;
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if content has no markup and no entities.
|
* 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) {
|
if (content.refs) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var {blocks} = content;
|
var {blocks} = content;
|
||||||
|
|
||||||
|
if (noLineBreaks && blocks.length > 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i in blocks) {
|
for (var i in blocks) {
|
||||||
if (blocks[i].fmt) {
|
if (blocks[i].fmt) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
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') {
|
if (typeof(environment.Drafty) === 'undefined') {
|
||||||
environment.Drafty = Drafty();
|
environment.Drafty = Drafty();
|
||||||
}
|
}
|
||||||
})(this); // 'this' will be window object for the browsers.
|
})(this); // 'this' will be a window object for the browsers.
|
||||||
|
|||||||
+31
-3
@@ -2027,12 +2027,38 @@
|
|||||||
|
|
||||||
var Message = React.createClass({
|
var Message = React.createClass({
|
||||||
render: function() {
|
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 sideClass = this.props.sequence + " " + (this.props.response ? "left" : "right");
|
||||||
var bubbleClass = (this.props.sequence === "single" || this.props.sequence === "last") ?
|
var bubbleClass = (this.props.sequence === "single" || this.props.sequence === "last") ?
|
||||||
"bubble tip" : "bubble";
|
"bubble tip" : "bubble";
|
||||||
var avatar = this.props.userAvatar || true;
|
var avatar = this.props.userAvatar || true;
|
||||||
var fullDisplay = (this.props.userFrom && this.props.response &&
|
var fullDisplay = (this.props.userFrom && this.props.response &&
|
||||||
(this.props.sequence === "single" || this.props.sequence === "last"));
|
(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 (
|
return (
|
||||||
<li className={sideClass}>
|
<li className={sideClass}>
|
||||||
{this.props.userFrom && this.props.response ?
|
{this.props.userFrom && this.props.response ?
|
||||||
@@ -2045,7 +2071,7 @@
|
|||||||
null}
|
null}
|
||||||
<div>
|
<div>
|
||||||
<div className={bubbleClass}>
|
<div className={bubbleClass}>
|
||||||
<p>{this.props.content}
|
<p>{content}
|
||||||
<Message.ReceivedMarker response={this.props.response}
|
<Message.ReceivedMarker response={this.props.response}
|
||||||
timestamp={this.props.timestamp} received={this.props.received} />
|
timestamp={this.props.timestamp} received={this.props.received} />
|
||||||
</p>
|
</p>
|
||||||
@@ -2239,8 +2265,10 @@
|
|||||||
} else {
|
} else {
|
||||||
chatBoxClass="chat-box";
|
chatBoxClass="chat-box";
|
||||||
}
|
}
|
||||||
|
|
||||||
messageNodes.push(
|
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}
|
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
||||||
sequence={sequence} received={msgReceived} key={msg.seq} />
|
sequence={sequence} received={msgReceived} key={msg.seq} />
|
||||||
);
|
);
|
||||||
@@ -2622,7 +2650,7 @@
|
|||||||
var topic = Tinode.getTopic(this.state.topicSelected);
|
var topic = Tinode.getTopic(this.state.topicSelected);
|
||||||
// Check if the message is formatted.
|
// Check if the message is formatted.
|
||||||
var dft = Drafty.parse(msg);
|
var dft = Drafty.parse(msg);
|
||||||
if (Drafty.isPlain(dft)) {
|
if (Drafty.isPlainText(dft)) {
|
||||||
// The message is simple text. Sned if as text/plain.
|
// The message is simple text. Sned if as text/plain.
|
||||||
topic.publish(msg);
|
topic.publish(msg);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user