From fb6b4899cd68cfbc8847a00cdfed1836c21232e3 Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 21 Aug 2017 19:45:48 -0700 Subject: [PATCH] rich formatting works --- drafty.js | 179 ++++++++++++++++++++++++++++++++++++++++++++++------- index.html | 34 +++++++++- 2 files changed, 187 insertions(+), 26 deletions(-) diff --git a/drafty.js b/drafty.js index 27b0f537..cd631df6 100644 --- a/drafty.js +++ b/drafty.js @@ -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` -> abc // Nested frmatting is supported, e.g. *abc _def_* -> abc def // -// 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: ['', ''], - EM: ['', ''], - DL: ['', ''], - CO: ['', ''] + // 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 ''; }, close: function() { return ''; }}, + EM: { open: function() { return ''; }, close: function() { return ''}}, + DL: { open: function() { return ''; }, close: function() { return ''}}, + CO: { open: function() { return ''; }, close: function() { return ''}}, LN: { open: function(data) { return ''; }, - close: function(data) { return ''; } + close: function(data) { return ''; }, + props: function(data) { return { href: data.url, target: "_blank" }; }, }, MN: { open: function(data) { return ''; }, - close: function(data) { return ''; } + close: function(data) { return ''; }, + props: function(data) { return { name: data.val }; }, }, HT: { open: function(data) { return ''; }, - close: function(data) { return ''; } + close: function(data) { return ''; }, + 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("
"); }, - /* + /** + * 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. diff --git a/index.html b/index.html index a2a68368..109a3b62 100644 --- a/index.html +++ b/index.html @@ -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") ?
: line; + }); + } + return (
  • {this.props.userFrom && this.props.response ? @@ -2045,7 +2071,7 @@ null}
    -

    {this.props.content} +

    {content}

    @@ -2239,8 +2265,10 @@ } else { chatBoxClass="chat-box"; } + messageNodes.push( - ); @@ -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 {