diff --git a/drafty.js b/drafty.js index 722ab0a3..394cee35 100644 --- a/drafty.js +++ b/drafty.js @@ -1,22 +1,186 @@ -// Basic parser and formatter for simple rich text. -// JSON data representation is similar to Draft.js +// Basic parser and formatter for very simple rich text. Mostly targeted at +// mobile use cases similar to Telegram and WhatsApp. +// +// Supports: +// *abc* -> abc +// _abc_ -> abc +// ~abc~ -> abc +// `abc` -> abc +// Nested frmatting is supported, e.g. *abc _def_* -> abc def +// +// URLs, @mentions, and #hashtags are extracted. +// +// JSON data representation is similar to Draft.js. + (function(environment) { // closure for web browsers 'use strict'; 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' -> + // ('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. + function chunkify(line, start, end, spans) { + var chunks = []; + + if (spans.length == 0) { + return []; + } + + for (var i in spans) { + // Get the next chunk from the queue + var span = spans[i]; + + // Grab the initial unstyled chunk + if (span.start > start) { + chunks.push({text: line.slice(start, span.start)}); + } + + // Grab the styled chunk. It may include subchunks. + var chunk = {type: span.type}; + var chld = chunkify(line, span.start + 1, span.end - 1, span.children); + if (chld.length > 0) { + chunk.children = chld; + } else { + chunk.text = span.text; + } + chunks.push(chunk); + start = span.end + 1; // '+1' is to skip the formatting character + } + + // Grab the remaining unstyled chunk, after the last span + if (start < end) { + chunks.push({text: line.slice(start, end)}); + } + + return chunks; + } + + // Detect starts and ends of formatting spans + function spannify(original, re_start, re_end, type) { + var result = []; + var index = 0; + var line = original.slice(0); // make a copy; + + while (line.length > 0) { + // match[0]; // match, like '*abc*' + // match[1]; // match captured in parenthesis, like 'abc' + // match['index']; // offset where the match started. + + // Find the opening token. + var start = re_start.exec(line); + if (start == null) { + break; + } + // 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); + // start_offset is an offset within the clipped string. Convert to original index. + start_offset += index; + // Index now point to the beginning of 'line' within the 'original' string. + index = start_offset + 1; + + // Find the matching closing token. + var end = re_end.exec(line); + if (end == null) { + break; + } + var end_offset = end['index'] + end[0].indexOf(end[1]); + // Clip the processed part of the string. + line = line.slice(end_offset + 1); + // Update offsets + end_offset += index; + // Index now point to the beginning of 'line' within the 'original' string. + index = end_offset + 1; + + result.push({ + text: original.slice(start_offset+1, end_offset), + children: [], + start: start_offset, + end: end_offset, + type: type + }); + } + + return result; + } + + function toTree(spans) { + if (spans.length == 0) { + return []; + } + + // Throw away overlapping spans. + var cleaned = [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 + // are complete within the previous span. + + if (spans[i].start > last.end) { + // Span is completely outside of the previous span. + cleaned.push(spans[i]); + last = spans[i]; + } else if (spans[i].end < last.end) { + // Span is fully inside of the previous span. Push to subnode. + last.children.push(spans[i]); + } + // Span could partially overlap, ignoring it as invalid. + } + + // Recursively rearrange the subnodes. + for (var i in cleaned) { + cleaned[i].children = toTree(cleaned[i].children); + } + + return cleaned; + } + + // finally convert the chunks to format suitable for serialization. + function toDrafty(chunks, startAt) { + var plain = ""; + var ranges = []; + for (var i in chunks) { + var chunk = chunks[i]; + if (!chunk.text) { + var drafty = toDrafty(chunk.children, plain.length + startAt); + chunk.text = drafty.text; + ranges = ranges.concat(drafty.ranges); + } + + if (chunk.type) { + ranges.push({offset: plain.length + startAt, len: chunk.text.length, style: chunk.type}); + } + + plain += chunk.text; + } + return {text: plain, ranges: ranges}; + } + + // Splice two strings: insert second string into the first one at the given index + function splice(src, at, insert) { + return src.slice(0, at) + insert + src.slice(at); + } + return { parse: function(content) { // Regular expressions for parsing inline formats var inlineStyles = [ // Bold, *bold text* - {name: "BO", re: /(\*(?:[^\s*]|(?:[^\s*]*[^*]*[^\s*]))\*)/g}, + {name: "BO", start: /(?:^|\W)(\*)[^\s*]/, end: /[^\s*](\*)(?:$|\W)/}, // Italic, _italic text_ - {name: "IT", re: /(_(?:[^\s_]|(?:[^\s_]*[^_]*[^\s_]))_)/g}, + {name: "IT", start: /(?:^|[\W_])(_)[^\s_]/, end: /[^\s_](_)(?:$|[\W_])/}, // Strikethough, ~strike this though~ - {name: "ST", re: /(~(?:[^\s~]|(?:[^\s~]*[^~]*[^\s~]))~)/g}, + {name: "ST", start: /(?:^|\W)(~)[^\s~]/g, end: /[^\s~](~)(?:$|\W)/}, // Code block `this is monospace` - {name: "CO", re: /(`[^`]+`)/g} + {name: "CO", start: /(?:^|\W)(`)[^`]/, end: /[^`](`)(?:$|\W)/} ]; var entities = [ @@ -37,79 +201,31 @@ // lines.map(function(line) { - console.log("processing line: '" + line + "'"); var spans = []; // Find formatted spans in the string. // Try to match each style. inlineStyles.map(function(style) { - var match; // Each style could be matched multiple times. - while ((match = style.re.exec(line)) !== null) { - // match[0]; // match, like '*abc*' - // match[1]; // matched text in parenthesis, like 'abc' - // match['index']; // offset where the match started. - spans.push({ - match: match[1], - text: match[1].substring(1, match[1].length-1), - offset: match['index'], - len: match[1].length, - type: style.name - }); - } + spans = spans.concat(spannify(line, style.start, style.end, style.name)); }); - var ranges = []; if (spans.length == 0) { blocks.push({text: line}); } else { - console.log(JSON.stringify(spans)); - // Sort spans by style occurence early -> late spans.sort(function(a,b) { - return a.offset - b.offset; + return a.start - b.start; }); - // Clear formatting characters from the string, leave just the plain text. - // Save formatting to inlineStylesRanges array - var chunks = []; - var start = 0; - for (var i = 0; i < spans.length; i++) { - // Skip empty or invalid (overlapping) chunk. - if (start > spans[i].offset) { - continue; - } + // Convert an array of possibly overlapping spans into a tree + spans = toTree(spans); - // Grab the initial unstyled chunk - if (start < spans[i].offset) { - chunks.push({text: line.slice(start, spans[i].offset)}); - start = spans[i].offset; - console.log(i + ": " + chunks[chunks.length-1].text); - } + // Build tree representation of the format. + var chunks = chunkify(line, 0, line.length, spans); - // Grab the styled chunk - chunks.push({text: line.slice(start, start + spans[i].len), style: spans[i]}); - console.log(i + ": " + chunks[chunks.length-1].text); - // And grab the unstyled span till the next chunk - start = start + spans[i].len; - var end = (i < spans.length - 1) ? spans[i+1].offset : undefined; - chunks.push({text: line.slice(start, end)}); - console.log(i + ": " + chunks[chunks.length-1].text); - start = end; // does not matter if undefined. - } - console.log("CHUNKS"); - console.log(JSON.stringify(chunks)); - /* - var offset = spans[i].offset - shiftBy; - line = line.slice(0, offset) + - spans[i].text + - line.slice(offset + spans[i].len); - console.log(line); - shiftBy += 2; - // Save style - ranges.push({offset: offset, length: spans[i].len - 2, type: spans[i].type}); - } - */ - blocks.push({text: line, styleRanges: ranges}); + var drafty = toDrafty(chunks, 0); + + blocks.push({text: drafty.text, styles: drafty.ranges}); } }); @@ -126,12 +242,21 @@ var {blocks} = json; var text = ""; for (var i in blocks) { - var shiftBy = 0; var line = blocks[i].text; - if (blocks[i].styleRanges) { - for (var j in blocks[i].styleRanges) { - var range = blocks[i].styleRanges[j]; - line = line.slice(0, range.offset + shiftBy); + var markup = []; + 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.sort(function(a, b) { + return b.idx - a.idx; // in descending order + }); + + for (var j in markup) { + line = splice(line, markup[j].idx, markup[j].what); } } text += line + "
";