From f20f83b936f2161be7ac2a6e35ac89eff16ca134 Mon Sep 17 00:00:00 2001 From: or-else Date: Thu, 17 Aug 2017 13:07:32 -0700 Subject: [PATCH] urls, mentions, and hashtags are extracted and formatted correctly --- drafty.js | 210 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 158 insertions(+), 52 deletions(-) diff --git a/drafty.js b/drafty.js index 394cee35..9d6eeb2a 100644 --- a/drafty.js +++ b/drafty.js @@ -10,22 +10,77 @@ // // URLs, @mentions, and #hashtags are extracted. // -// JSON data representation is similar to Draft.js. +// JSON data representation is similar to Draft.js raw formatting. (function(environment) { // closure for web browsers 'use strict'; + // Regular expressions for parsing inline formats. Javascript does not support lookbehind, + // so it's a bit messy. + var INLINE_STYLES = [ + // Bold, *bold text* + {name: "BO", start: /(?:^|\W)(\*)[^\s*]/, end: /[^\s*](\*)(?=$|\W)/}, + // Italic, _italic text_ + {name: "IT", start: /(?:^|[\W_])(_)[^\s_]/, end: /[^\s_](_)(?=$|[\W_])/}, + // Strikethough, ~strike this though~ + {name: "ST", start: /(?:^|\W)(~)[^\s~]/g, end: /[^\s~](~)(?=$|\W)/}, + // Code block `this is monospace` + {name: "CO", start: /(?:^|\W)(`)[^`]/, end: /[^`](`)(?=$|\W)/} + ]; + + // RegExps for entity extraction + var ENTITY_TYPES = [ + // URLs + {name: "LN", dataName: "url", + pack: function(val) { + // Check if the protocol is pecified, if not use http + if (!/^[a-z]+:\/\//i.test(val)) { + val = 'http://' + val; + } + return {url: val}; + }, + re: /(https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b(?:[-a-zA-Z0-9@:%_\+.~#?&//=]*)/g}, + // Mentions @user (must be 2 or more characters) + {name: "MN", dataName: "val", + pack: function(val) { return {val: val.slice(1)}; }, + re: /\B@(\w\w+)/g}, + // Hashtags #hashtag + {name: "HT", dataName: "val", + pack: function(val) { return {val: val.slice(1)}; }, + re: /\B#(\w\w+)/g} + ]; + + // Formatting HTML tags + var STYLE_DECOR = { + BO: ['', ''], + IT: ['', ''], + ST: ['', ''], + CO: ['', ''] + }; + + var ENTITY_DECOR = { + LN: { + open: function(data) { return ''; }, + close: function(data) { return ''; } + }, + MN: { + open: function(data) { return ''; }, + close: function(data) { return ''; } + }, + HT: { + open: function(data) { return ''; }, + close: function(data) { return ''; } + } + }; + var Drafty = (function() { // Take a string and defined earlier style spans, re-compose them into a tree where each leaf is - // a same-style (or no style) string. I.e. 'hello *bold _italic_* and ~more~ world' -> + // a same-style (including unstyled) string. I.e. 'hello *bold _italic_* and ~more~ world' -> // ('hello ', (b: 'bold ', (i: 'italic')), ' and ', (s: 'more'), ' world'); // // This is needed in order to clear markup, i.e. 'hello *world*' -> 'hello world' and convert - // ranges from markup-ed offsets to plain text offsets; - // - // Take string and chunks. Pick first chunk: 1. if it starts at the offset 0, clip the substring - // equal in length to the chunk. + // ranges from markup-ed offsets to plain text offsets. function chunkify(line, start, end, spans) { var chunks = []; @@ -62,7 +117,8 @@ return chunks; } - // Detect starts and ends of formatting spans + // Detect starts and ends of formatting spans. Unformatted spans are + // ignored at this time. function spannify(original, re_start, re_end, type) { var result = []; var index = 0; @@ -78,7 +134,8 @@ if (start == null) { break; } - // The actual offset may not be at the beginning of the match. Find it in the matched string. + // Because javascript RegExp does not support lookbehind, the actual offset may not be at the beginning of + // the match. Find it in the matched string. var start_offset = start['index'] + start[0].lastIndexOf(start[1]); // Clip the processed part of the string. line = line.slice(start_offset + 1); @@ -112,13 +169,14 @@ return result; } + // Convert linear array or spans into a tree representation. + // Keep standalone and nested spans, throw away partially overlapping spans. function toTree(spans) { if (spans.length == 0) { return []; } - // Throw away overlapping spans. - var cleaned = [spans[0]]; + var tree = [spans[0]]; var last = spans[0]; for (var i = 1; i < spans.length; i++) { // Keep spans which start after the end of the previous span or those which @@ -126,7 +184,7 @@ if (spans[i].start > last.end) { // Span is completely outside of the previous span. - cleaned.push(spans[i]); + tree.push(spans[i]); last = spans[i]; } else if (spans[i].end < last.end) { // Span is fully inside of the previous span. Push to subnode. @@ -136,14 +194,48 @@ } // Recursively rearrange the subnodes. - for (var i in cleaned) { - cleaned[i].children = toTree(cleaned[i].children); + for (var i in tree) { + tree[i].children = toTree(tree[i].children); } - return cleaned; + return tree; } - // finally convert the chunks to format suitable for serialization. + // Get a list of entities from a text. + function extractEntities(line, re, name) { + var match; + var extracted = []; + ENTITY_TYPES.map(function(ent) { + while((match = ent.re.exec(line)) !== null) { + extracted.push({ + offset: match['index'], + len: match[0].length, + unique: match[0], + data: ent.pack(match[0]), + type: ent.name}); + } + }); + + if (extracted.length == 0) { + return extracted; + } + + // Remove entities detected inside other entities, like #hashtag in a URL. + extracted.sort(function(a,b) { + return a.offset - b.offset; + }); + + var idx = -1; + extracted = extracted.filter(function(el) { + var result = (el.offset > idx); + idx = el.offset + el.len; + return result; + }); + + return extracted; + } + + // Convert the chunks into format suitable for serialization. function toDrafty(chunks, startAt) { var plain = ""; var ranges = []; @@ -171,46 +263,28 @@ return { parse: function(content) { - // Regular expressions for parsing inline formats - var inlineStyles = [ - // Bold, *bold text* - {name: "BO", start: /(?:^|\W)(\*)[^\s*]/, end: /[^\s*](\*)(?:$|\W)/}, - // Italic, _italic text_ - {name: "IT", start: /(?:^|[\W_])(_)[^\s_]/, end: /[^\s_](_)(?:$|[\W_])/}, - // Strikethough, ~strike this though~ - {name: "ST", start: /(?:^|\W)(~)[^\s~]/g, end: /[^\s~](~)(?:$|\W)/}, - // Code block `this is monospace` - {name: "CO", start: /(?:^|\W)(`)[^`]/, end: /[^`](`)(?:$|\W)/} - ]; - - var entities = [ - // URLs - {name: "LN", re: /(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/}, - // Mentions @user - {name: "MN", re: /@[\w]+/g}, - // Hashtags #hashtag - {name: "HT", re: /#[\w]+/g} - ]; - // Split text into lines. It makes formatting easier var lines = content.split('\n'); // Holds formatted lines var blocks = []; // Holds entities referenced from text - var entityMap = {}; - + var entityMap = []; + var entityIndex = {}; // lines.map(function(line) { var spans = []; + var entities = []; + // Find formatted spans in the string. // Try to match each style. - inlineStyles.map(function(style) { + INLINE_STYLES.map(function(style) { // Each style could be matched multiple times. spans = spans.concat(spannify(line, style.start, style.end, style.name)); }); + var block; if (spans.length == 0) { - blocks.push({text: line}); + block = {text: line}; } else { // Sort spans by style occurence early -> late spans.sort(function(a,b) { @@ -225,21 +299,42 @@ var drafty = toDrafty(chunks, 0); - blocks.push({text: drafty.text, styles: drafty.ranges}); + block = {text: drafty.text, styles: drafty.ranges}; } + + // Extract entities from the cleaned up string. + entities = extractEntities(block.text); + var ranges = []; + if (entities.length > 0) { + console.log(JSON.stringify(entities)); + for (var i in entities) { + // {offset: match['index'], unique: match[0], len: match[0].length, data: ent.packer(), type: ent.name} + var ent = entities[i]; + var index = entityIndex[ent.unique]; + if (!index) { + index = entityMap.length; + entityIndex[ent.unique] = index; + entityMap.push({type: ent.type, data: ent.data}); + } + ranges.push({offset: ent.offset, len: ent.len, key: index}); + } + block.ent = ranges; + } + + blocks.push(block); }); - return {blocks: blocks, entityMap: entityMap}; + var result = {blocks: blocks}; + if (entityMap.length > 0) { + result.refs = entityMap; + } + return result; }, - format: function(json) { - var TAGS = { - BO: ['', '', 7], - IT: ['', '', 7], - ST: ['', '', 11], - CO: ['', '', 9] - }; - var {blocks} = json; + format: function(content) { + var {blocks} = content; + var {refs} = content; + var text = ""; for (var i in blocks) { var line = blocks[i].text; @@ -247,10 +342,21 @@ if (blocks[i].styles) { for (var j in blocks[i].styles) { var range = blocks[i].styles[j]; - markup.push({idx: range.offset, what: TAGS[range.style][0]}); - markup.push({idx: range.offset + range.len, what: TAGS[range.style][1]}); + markup.push({idx: range.offset, what: STYLE_DECOR[range.style][0]}); + markup.push({idx: range.offset + range.len, what: STYLE_DECOR[range.style][1]}); } + } + if (blocks[i].ent) { + for (var j in blocks[i].ent) { + var range = blocks[i].ent[j]; + var entity = refs[range.key]; + markup.push({idx: range.offset, what: ENTITY_DECOR[entity.type].open(entity.data)}); + markup.push({idx: range.offset + range.len, what: ENTITY_DECOR[entity.type].close(entity.data)}); + } + } + + if (markup.length > 0) { markup.sort(function(a, b) { return b.idx - a.idx; // in descending order });