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
});