/**
* @copyright 2015-2022 Tinode LLC.
* @summary Minimally rich text representation and formatting for Tinode.
* @license Apache 2.0
*
* @file Basic parser and formatter for very simple text markup. Mostly targeted at
* mobile use cases similar to Telegram, WhatsApp, and FB Messenger.
*
* <p>Supports conversion of user keyboard input to formatted text:</p>
* <ul>
* <li>*abc* → <b>abc</b></li>
* <li>_abc_ → <i>abc</i></li>
* <li>~abc~ → <del>abc</del></li>
* <li>`abc` → <tt>abc</tt></li>
* </ul>
* Also supports forms and buttons.
*
* Nested formatting is supported, e.g. *abc _def_* -> <b>abc <i>def</i></b>
* URLs, @mentions, and #hashtags are extracted and converted into links.
* Forms and buttons can be added procedurally.
* JSON data representation is inspired by Draft.js raw formatting.
*
*
* @example
* Text:
* <pre>
* this is *bold*, `code` and _italic_, ~strike~
* combined *bold and _italic_*
* an url: https://www.example.com/abc#fragment and another _www.tinode.co_
* this is a @mention and a #hashtag in a string
* second #hashtag
* </pre>
*
* Sample JSON representation of the text above:
* {
* "txt": "this is bold, code and italic, strike combined bold and italic an url: https://www.example.com/abc#fragment " +
* "and another www.tinode.co this is a @mention and a #hashtag in a string second #hashtag",
* "fmt": [
* { "at":8, "len":4,"tp":"ST" },{ "at":14, "len":4, "tp":"CO" },{ "at":23, "len":6, "tp":"EM"},
* { "at":31, "len":6, "tp":"DL" },{ "tp":"BR", "len":1, "at":37 },{ "at":56, "len":6, "tp":"EM" },
* { "at":47, "len":15, "tp":"ST" },{ "tp":"BR", "len":1, "at":62 },{ "at":120, "len":13, "tp":"EM" },
* { "at":71, "len":36, "key":0 },{ "at":120, "len":13, "key":1 },{ "tp":"BR", "len":1, "at":133 },
* { "at":144, "len":8, "key":2 },{ "at":159, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":179 },
* { "at":187, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":195 }
* ],
* "ent": [
* { "tp":"LN", "data":{ "url":"https://www.example.com/abc#fragment" } },
* { "tp":"LN", "data":{ "url":"http://www.tinode.co" } },
* { "tp":"MN", "data":{ "val":"mention" } },
* { "tp":"HT", "data":{ "val":"hashtag" } }
* ]
* }
*/
'use strict';
// NOTE TO DEVELOPERS:
// Localizable strings should be double quoted "строка на другом языке",
// non-localizable strings should be single quoted 'non-localized'.
const MAX_FORM_ELEMENTS = 8;
const MAX_PREVIEW_ATTACHMENTS = 3;
const MAX_PREVIEW_DATA_SIZE = 64;
const JSON_MIME_TYPE = 'application/json';
const DRAFTY_MIME_TYPE = 'text/x-drafty';
const ALLOWED_ENT_FIELDS = ['act', 'height', 'duration', 'incoming', 'mime', 'name', 'premime', 'preref', 'preview',
'ref', 'size', 'state', 'url', 'val', 'width'
];
// Regular expressions for parsing inline formats. Javascript does not support lookbehind,
// so it's a bit messy.
const INLINE_STYLES = [
// Strong = bold, *bold text*
{
name: 'ST',
start: /(?:^|[\W_])(\*)[^\s*]/,
end: /[^\s*](\*)(?=$|[\W_])/
},
// Emphesized = italic, _italic text_
{
name: 'EM',
start: /(?:^|\W)(_)[^\s_]/,
end: /[^\s_](_)(?=$|\W)/
},
// Deleted, ~strike this though~
{
name: 'DL',
start: /(?:^|[\W_])(~)[^\s~]/,
end: /[^\s~](~)(?=$|[\W_])/
},
// Code block `this is monospace`
{
name: 'CO',
start: /(?:^|\W)(`)[^`]/,
end: /[^`](`)(?=$|\W)/
}
];
// Relative weights of formatting spans. Greater index in array means greater weight.
const FMT_WEIGHT = ['QQ'];
// RegExps for entity extraction (RF = reference)
const ENTITY_TYPES = [
// URLs
{
name: 'LN',
dataName: 'url',
pack: function(val) {
// Check if the protocol is specified, if not use http
if (!/^[a-z]+:\/\//i.test(val)) {
val = 'http://' + val;
}
return {
url: val
};
},
re: /(?:(?:https?|ftp):\/\/|www\.|ftp\.)[-A-Z0-9+&@#\/%=~_|$?!:,.]*[A-Z0-9+&@#\/%=~_|$]/ig
},
// Mentions @user (must be 2 or more characters)
{
name: 'MN',
dataName: 'val',
pack: function(val) {
return {
val: val.slice(1)
};
},
re: /\B@([\p{L}\p{N}][._\p{L}\p{N}]*[\p{L}\p{N}])/ug
},
// Hashtags #hashtag, like metion 2 or more characters.
{
name: 'HT',
dataName: 'val',
pack: function(val) {
return {
val: val.slice(1)
};
},
re: /\B#([\p{L}\p{N}][._\p{L}\p{N}]*[\p{L}\p{N}])/ug
}
];
// HTML tag name suggestions
const FORMAT_TAGS = {
AU: {
html_tag: 'audio',
md_tag: undefined,
isVoid: false
},
BN: {
html_tag: 'button',
md_tag: undefined,
isVoid: false
},
BR: {
html_tag: 'br',
md_tag: '\n',
isVoid: true
},
CO: {
html_tag: 'tt',
md_tag: '`',
isVoid: false
},
DL: {
html_tag: 'del',
md_tag: '~',
isVoid: false
},
EM: {
html_tag: 'i',
md_tag: '_',
isVoid: false
},
EX: {
html_tag: '',
md_tag: undefined,
isVoid: true
},
FM: {
html_tag: 'div',
md_tag: undefined,
isVoid: false
},
HD: {
html_tag: '',
md_tag: undefined,
isVoid: false
},
HL: {
html_tag: 'span',
md_tag: undefined,
isVoid: false
},
HT: {
html_tag: 'a',
md_tag: undefined,
isVoid: false
},
IM: {
html_tag: 'img',
md_tag: undefined,
isVoid: false
},
LN: {
html_tag: 'a',
md_tag: undefined,
isVoid: false
},
MN: {
html_tag: 'a',
md_tag: undefined,
isVoid: false
},
RW: {
html_tag: 'div',
md_tag: undefined,
isVoid: false,
},
QQ: {
html_tag: 'div',
md_tag: undefined,
isVoid: false
},
ST: {
html_tag: 'b',
md_tag: '*',
isVoid: false
},
VC: {
html_tag: 'div',
md_tag: undefined,
isVoid: false
},
VD: {
html_tag: 'video',
md_tag: undefined,
isVoid: false
}
};
// Convert base64-encoded string into Blob.
function base64toObjectUrl(b64, contentType, logger) {
if (!b64) {
return null;
}
try {
const bin = atob(b64);
const length = bin.length;
const buf = new ArrayBuffer(length);
const arr = new Uint8Array(buf);
for (let i = 0; i < length; i++) {
arr[i] = bin.charCodeAt(i);
}
return URL.createObjectURL(new Blob([buf], {
type: contentType
}));
} catch (err) {
if (logger) {
logger("Drafty: failed to convert object.", err.message);
}
}
return null;
}
function base64toDataUrl(b64, contentType) {
if (!b64) {
return null;
}
contentType = contentType || 'image/jpeg';
return 'data:' + contentType + ';base64,' + b64;
}
// Helpers for converting Drafty to HTML.
const DECORATORS = {
// Visial styles
ST: {
open: _ => '<b>',
close: _ => '</b>'
},
EM: {
open: _ => '<i>',
close: _ => '</i>'
},
DL: {
open: _ => '<del>',
close: _ => '</del>'
},
CO: {
open: _ => '<tt>',
close: _ => '</tt>'
},
// Line break
BR: {
open: _ => '<br/>',
close: _ => ''
},
// Hidden element
HD: {
open: _ => '',
close: _ => ''
},
// Highlighted element.
HL: {
open: _ => '<span style="color:teal">',
close: _ => '</span>'
},
// Link (URL)
LN: {
open: (data) => {
return '<a href="' + data.url + '">';
},
close: _ => '</a>',
props: (data) => {
return data ? {
href: data.url,
target: '_blank'
} : null;
},
},
// Mention
MN: {
open: (data) => {
return '<a href="#' + data.val + '">';
},
close: _ => '</a>',
props: (data) => {
return data ? {
id: data.val
} : null;
},
},
// Hashtag
HT: {
open: (data) => {
return '<a href="#' + data.val + '">';
},
close: _ => '</a>',
props: (data) => {
return data ? {
id: data.val
} : null;
},
},
// Button
BN: {
open: _ => '<button>',
close: _ => '</button>',
props: (data) => {
return data ? {
'data-act': data.act,
'data-val': data.val,
'data-name': data.name,
'data-ref': data.ref
} : null;
},
},
// Audio recording
AU: {
open: (data) => {
const url = data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger);
return '<audio controls src="' + url + '">';
},
close: _ => '</audio>',
props: (data) => {
if (!data) return null;
return {
// Embedded data or external link.
src: data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
'data-preload': data.ref ? 'metadata' : 'auto',
'data-duration': data.duration,
'data-name': data.name,
'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0),
'data-mime': data.mime,
};
}
},
// Image
IM: {
open: data => {
// Don't use data.ref for preview: it's a security risk.
const tmpPreviewUrl = base64toDataUrl(data._tempPreview, data.mime);
const previewUrl = base64toObjectUrl(data.val, data.mime, Drafty.logger);
const downloadUrl = data.ref || previewUrl;
return (data.name ? '<a href="' + downloadUrl + '" download="' + data.name + '">' : '') +
'<img src="' + (tmpPreviewUrl || previewUrl) + '"' +
(data.width ? ' width="' + data.width + '"' : '') +
(data.height ? ' height="' + data.height + '"' : '') + ' border="0" />';
},
close: data => {
return (data.name ? '</a>' : '');
},
props: data => {
if (!data) return null;
return {
// Temporary preview, or permanent preview, or external link.
src: base64toDataUrl(data._tempPreview, data.mime) ||
data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
title: data.name,
alt: data.name,
'data-width': data.width,
'data-height': data.height,
'data-name': data.name,
'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0),
'data-mime': data.mime,
};
},
},
// Form - structured layout of elements.
FM: {
open: _ => '<div>',
close: _ => '</div>'
},
// Row: logic grouping of elements
RW: {
open: _ => '<div>',
close: _ => '</div>'
},
// Quoted block.
QQ: {
open: _ => '<div>',
close: _ => '</div>',
props: (data) => {
return data ? {} : null;
},
},
// Video call
VC: {
open: _ => '<div>',
close: _ => '</div>',
props: data => {
if (!data) return {};
return {
'data-duration': data.duration,
'data-state': data.state,
};
}
},
// Video.
VD: {
open: data => {
const tmpPreviewUrl = base64toDataUrl(data._tempPreview, data.mime);
const previewUrl = data.ref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger);
return '<img src="' + (tmpPreviewUrl || previewUrl) + '"' +
(data.width ? ' width="' + data.width + '"' : '') +
(data.height ? ' height="' + data.height + '"' : '') + ' border="0" />';
},
close: _ => '',
props: data => {
if (!data) return null;
const poster = data.preref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger);
return {
// Embedded data or external link.
src: poster,
'data-src': data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
'data-width': data.width,
'data-height': data.height,
'data-preload': data.ref ? 'metadata' : 'auto',
'data-preview': poster,
'data-duration': data.duration | 0,
'data-name': data.name,
'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0),
'data-mime': data.mime,
};
}
},
};
/**
* The main object which performs all the formatting actions.
* @class Drafty
* @constructor
*/
const Drafty = function() {
this.txt = '';
this.fmt = [];
this.ent = [];
}
/**
* Initialize Drafty document to a plain text string.
*
* @param {string} plainText - string to use as Drafty content.
*
* @returns new Drafty document or null is plainText is not a string or undefined.
*/
Drafty.init = function(plainText) {
if (typeof plainText == 'undefined') {
plainText = '';
} else if (typeof plainText != 'string') {
return null;
}
return {
txt: plainText
};
}
/**
* Parse plain text into Drafty document.
* @memberof Drafty
* @static
*
* @param {string} content - plain-text content to parse.
* @return {Drafty} parsed document or null if the source is not plain text.
*/
Drafty.parse = function(content) {
// Make sure we are parsing strings only.
if (typeof content != 'string') {
return null;
}
// Split text into lines. It makes further processing easier.
const lines = content.split(/\r?\n/);
// Holds entities referenced from text
const entityMap = [];
const entityIndex = {};
// Processing lines one by one, hold intermediate result in blx.
const blx = [];
lines.forEach((line) => {
let spans = [];
let entities;
// Find formatted spans in the string.
// Try to match each style.
INLINE_STYLES.forEach((tag) => {
// Each style could be matched multiple times.
spans = spans.concat(spannify(line, tag.start, tag.end, tag.name));
});
let block;
if (spans.length == 0) {
block = {
txt: line
};
} else {
// Sort spans by style occurence early -> late, then by length: first long then short.
spans.sort((a, b) => {
const diff = a.at - b.at;
return diff != 0 ? diff : b.end - a.end;
});
// Convert an array of possibly overlapping spans into a tree.
spans = toSpanTree(spans);
// Build a tree representation of the entire string, not
// just the formatted parts.
const chunks = chunkify(line, 0, line.length, spans);
const drafty = draftify(chunks, 0);
block = {
txt: drafty.txt,
fmt: drafty.fmt
};
}
// Extract entities from the cleaned up string.
entities = extractEntities(block.txt);
if (entities.length > 0) {
const ranges = [];
for (let i in entities) {
// {offset: match['index'], unique: match[0], len: match[0].length, data: ent.packer(), type: ent.name}
const entity = entities[i];
let index = entityIndex[entity.unique];
if (!index) {
index = entityMap.length;
entityIndex[entity.unique] = index;
entityMap.push({
tp: entity.type,
data: entity.data
});
}
ranges.push({
at: entity.offset,
len: entity.len,
key: index
});
}
block.ent = ranges;
}
blx.push(block);
});
const result = {
txt: ''
};
// Merge lines and save line breaks as BR inline formatting.
if (blx.length > 0) {
result.txt = blx[0].txt;
result.fmt = (blx[0].fmt || []).concat(blx[0].ent || []);
for (let i = 1; i < blx.length; i++) {
const block = blx[i];
const offset = result.txt.length + 1;
result.fmt.push({
tp: 'BR',
len: 1,
at: offset - 1
});
result.txt += ' ' + block.txt;
if (block.fmt) {
result.fmt = result.fmt.concat(block.fmt.map((s) => {
s.at += offset;
return s;
}));
}
if (block.ent) {
result.fmt = result.fmt.concat(block.ent.map((s) => {
s.at += offset;
return s;
}));
}
}
if (result.fmt.length == 0) {
delete result.fmt;
}
if (entityMap.length > 0) {
result.ent = entityMap;
}
}
return result;
}
/**
* Append one Drafty document to another.
*
* @param {Drafty} first - Drafty document to append to.
* @param {Drafty|string} second - Drafty document or string being appended.
*
* @return {Drafty} first document with the second appended to it.
*/
Drafty.append = function(first, second) {
if (!first) {
return second;
}
if (!second) {
return first;
}
first.txt = first.txt || '';
const len = first.txt.length;
if (typeof second == 'string') {
first.txt += second;
} else if (second.txt) {
first.txt += second.txt;
}
if (Array.isArray(second.fmt)) {
first.fmt = first.fmt || [];
if (Array.isArray(second.ent)) {
first.ent = first.ent || [];
}
second.fmt.forEach(src => {
const fmt = {
at: (src.at | 0) + len,
len: src.len | 0
};
// Special case for the outside of the normal rendering flow styles.
if (src.at == -1) {
fmt.at = -1;
fmt.len = 0;
}
if (src.tp) {
fmt.tp = src.tp;
} else {
fmt.key = first.ent.length;
first.ent.push(second.ent[src.key || 0]);
}
first.fmt.push(fmt);
});
}
return first;
}
/**
* Description of an image to attach.
* @typedef {Object} ImageDesc
* @memberof Drafty
*
* @property {string} mime - mime-type of the image, e.g. "image/png".
* @property {string} refurl - reference to the content. Could be null/undefined.
* @property {string} bits - base64-encoded image content. Could be null/undefined.
* @property {string} preview - base64-encoded thumbnail of the image.
* @property {integer} width - width of the image.
* @property {integer} height - height of the image.
* @property {string} filename - file name suggestion for downloading the image.
* @property {integer} size - size of the image in bytes. Treat is as an untrusted hint.
* @property {string} _tempPreview - base64-encoded image preview used during upload process; not serializable.
* @property {Promise} urlPromise - Promise which returns content URL when resolved.
*/
/**
* Insert inline image into Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to add image to.
* @param {integer} at - index where the object is inserted. The length of the image is always 1.
* @param {ImageDesc} imageDesc - object with image paramenets and data.
*
* @return {Drafty} updated document.
*/
Drafty.insertImage = function(content, at, imageDesc) {
content = content || {
txt: ' '
};
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: at | 0,
len: 1,
key: content.ent.length
});
const ex = {
tp: 'IM',
data: {
mime: imageDesc.mime,
ref: imageDesc.refurl,
val: imageDesc.bits || imageDesc.preview,
width: imageDesc.width,
height: imageDesc.height,
name: imageDesc.filename,
size: imageDesc.size | 0,
}
};
if (imageDesc.urlPromise) {
ex.data._tempPreview = imageDesc._tempPreview;
ex.data._processing = true;
imageDesc.urlPromise.then(
url => {
ex.data.ref = url;
ex.data._tempPreview = undefined;
ex.data._processing = undefined;
},
_ => {
// Catch the error, otherwise it will appear in the console.
ex.data._processing = undefined;
}
);
}
content.ent.push(ex);
return content;
}
/**
* Description of a video to attach.
* @typedef {Object} VideoDesc
* @memberof Drafty
*
* @property {string} mime - mime-type of the video, e.g. "video/mpeg".
* @property {string} refurl - reference to the content. Could be null/undefined.
* @property {string} bits - in-band base64-encoded image data. Could be null/undefined.
* @property {string} preview - base64-encoded screencapture from the video. Could be null/undefined.
* @property {string} preref - reference to screencapture from the video. Could be null/undefined.
* @property {integer} width - width of the video.
* @property {integer} height - height of the video.
* @property {integer} duration - duration of the video.
* @property {string} filename - file name suggestion for downloading the video.
* @property {integer} size - size of the video in bytes. Treat is as an untrusted hint.
* @property {string} _tempPreview - base64-encoded screencapture used during upload process; not serializable.
* @property {Promise} urlPromise - array of two promises, which return URLs of video and preview uploads correspondingly
* (either could be null).
*/
/**
* Insert inline image into Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to add video to.
* @param {integer} at - index where the object is inserted. The length of the video is always 1.
* @param {VideoDesc} videoDesc - object with video paramenets and data.
*
* @return {Drafty} updated document.
*/
Drafty.insertVideo = function(content, at, videoDesc) {
content = content || {
txt: ' '
};
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: at | 0,
len: 1,
key: content.ent.length
});
const ex = {
tp: 'VD',
data: {
mime: videoDesc.mime,
ref: videoDesc.refurl,
val: videoDesc.bits,
preref: videoDesc.preref,
preview: videoDesc.preview,
width: videoDesc.width,
height: videoDesc.height,
duration: videoDesc.duration | 0,
name: videoDesc.filename,
size: videoDesc.size | 0,
}
};
if (videoDesc.urlPromise) {
ex.data._tempPreview = videoDesc._tempPreview;
ex.data._processing = true;
videoDesc.urlPromise.then(
urls => {
ex.data.ref = urls[0];
ex.data.preref = urls[1];
ex.data._tempPreview = undefined;
ex.data._processing = undefined;
},
_ => {
// Catch the error, otherwise it will appear in the console.
ex.data._processing = undefined;
}
);
}
content.ent.push(ex);
return content;
}
/**
* Description of an audio recording to attach.
* @typedef {Object} AudioDesc
* @memberof Drafty
*
* @property {string} mime - mime-type of the audio, e.g. "audio/ogg".
* @property {string} refurl - reference to the content. Could be null/undefined.
* @property {string} bits - base64-encoded audio content. Could be null/undefined.
* @property {integer} duration - duration of the record in milliseconds.
* @property {string} preview - base64 encoded short array of amplitude values 0..100.
* @property {string} filename - file name suggestion for downloading the audio.
* @property {integer} size - size of the recording in bytes. Treat is as an untrusted hint.
* @property {Promise} urlPromise - Promise which returns content URL when resolved.
*/
/**
* Insert audio recording into Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to add audio record to.
* @param {integer} at - index where the object is inserted. The length of the record is always 1.
* @param {AudioDesc} audioDesc - object with the audio paramenets and data.
*
* @return {Drafty} updated document.
*/
Drafty.insertAudio = function(content, at, audioDesc) {
content = content || {
txt: ' '
};
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: at | 0,
len: 1,
key: content.ent.length
});
const ex = {
tp: 'AU',
data: {
mime: audioDesc.mime,
val: audioDesc.bits,
duration: audioDesc.duration | 0,
preview: audioDesc.preview,
name: audioDesc.filename,
size: audioDesc.size | 0,
ref: audioDesc.refurl
}
};
if (audioDesc.urlPromise) {
ex.data._processing = true;
audioDesc.urlPromise.then(
url => {
ex.data.ref = url;
ex.data._processing = undefined;
},
_ => {
// Catch the error, otherwise it will appear in the console.
ex.data._processing = undefined;
}
);
}
content.ent.push(ex);
return content;
}
/**
* Create a (self-contained) video call Drafty document.
* @memberof Drafty
* @static
* @param {boolean} audioOnly <code>true</code> if the call is initially audio-only.
* @returns Video Call drafty document.
*/
Drafty.videoCall = function(audioOnly) {
const content = {
txt: ' ',
fmt: [{
at: 0,
len: 1,
key: 0
}],
ent: [{
tp: 'VC',
data: {
aonly: audioOnly
},
}]
};
return content;
}
/**
* Update video call (VC) entity with the new status and duration.
* @memberof Drafty
* @static
*
* @param {Drafty} content - VC document to update.
* @param {object} params - new video call parameters.
* @param {string} params.state - state of video call.
* @param {number} params.duration - duration of the video call in milliseconds.
*
* @returns the same document with update applied.
*/
Drafty.updateVideoCall = function(content, params) {
// The video element could be just a format or a format + entity.
// Must ensure it's the latter first.
const fmt = ((content || {}).fmt || [])[0];
if (!fmt) {
// Unrecognized content.
return content;
}
let ent;
if (fmt.tp == 'VC') {
// Just a format, convert to format + entity.
delete fmt.tp;
fmt.key = 0;
ent = {
tp: 'VC'
};
content.ent = [ent];
} else {
ent = (content.ent || [])[fmt.key | 0];
if (!ent || ent.tp != 'VC') {
// Not a VC entity.
return content;
}
}
ent.data = ent.data || {};
Object.assign(ent.data, params);
return content;
}
/**
* Create a quote to Drafty document.
* @memberof Drafty
* @static
*
* @param {string} header - Quote header (title, etc.).
* @param {string} uid - UID of the author to mention.
* @param {Drafty} body - Body of the quoted message.
*
* @returns Reply quote Drafty doc with the quote formatting.
*/
Drafty.quote = function(header, uid, body) {
const quote = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, uid)), body);
// Wrap into a quote.
quote.fmt.push({
at: 0,
len: quote.txt.length,
tp: 'QQ'
});
return quote;
}
/**
* Create a Drafty document with a mention.
*
* @param {string} name - mentioned name.
* @param {string} uid - mentioned user ID.
*
* @returns {Drafty} document with the mention.
*/
Drafty.mention = function(name, uid) {
return {
txt: name || '',
fmt: [{
at: 0,
len: (name || '').length,
key: 0
}],
ent: [{
tp: 'MN',
data: {
val: uid
}
}]
};
}
/**
* Append a link to a Drafty document.
*
* @param {Drafty} content - Drafty document to append link to.
* @param {Object} linkData - Link info in format <code>{txt: 'ankor text', url: 'http://...'}</code>.
*
* @returns {Drafty} the same document as <code>content</code>.
*/
Drafty.appendLink = function(content, linkData) {
content = content || {
txt: ''
};
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: content.txt.length,
len: linkData.txt.length,
key: content.ent.length
});
content.txt += linkData.txt;
const ex = {
tp: 'LN',
data: {
url: linkData.url
}
}
content.ent.push(ex);
return content;
}
/**
* Append image to Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to add image to.
* @param {ImageDesc} imageDesc - object with image paramenets.
*
* @return {Drafty} updated document.
*/
Drafty.appendImage = function(content, imageDesc) {
content = content || {
txt: ''
};
content.txt += ' ';
return Drafty.insertImage(content, content.txt.length - 1, imageDesc);
}
/**
* Append audio recodring to Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to add recording to.
* @param {AudioDesc} audioDesc - object with audio data.
*
* @return {Drafty} updated document.
*/
Drafty.appendAudio = function(content, audioDesc) {
content = content || {
txt: ''
};
content.txt += ' ';
return Drafty.insertAudio(content, content.txt.length - 1, audioDesc);
}
/**
* Description of a file to attach.
* @typedef {Object} AttachmentDesc
* @memberof Drafty
*
* @property {string} mime - mime-type of the attachment, e.g. "application/octet-stream"
* @property {string} data - base64-encoded in-band content of small attachments. Could be null/undefined.
* @property {string} filename - file name suggestion for downloading the attachment.
* @property {integer} size - size of the file in bytes. Treat is as an untrusted hint.
* @property {string} refurl - reference to the out-of-band content. Could be null/undefined.
* @property {Promise} urlPromise - Promise which returns content URL when resolved.
*/
/**
* Attach file to Drafty content. Either as a blob or as a reference.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to attach file to.
* @param {AttachmentDesc} object - containing attachment description and data.
*
* @return {Drafty} updated document.
*/
Drafty.attachFile = function(content, attachmentDesc) {
content = content || {
txt: ''
};
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: -1,
len: 0,
key: content.ent.length
});
const ex = {
tp: 'EX',
data: {
mime: attachmentDesc.mime,
val: attachmentDesc.data,
name: attachmentDesc.filename,
ref: attachmentDesc.refurl,
size: attachmentDesc.size | 0
}
}
if (attachmentDesc.urlPromise) {
ex.data._processing = true;
attachmentDesc.urlPromise.then(
url => {
ex.data.ref = url;
ex.data._processing = undefined;
},
_ => {
/* catch the error, otherwise it will appear in the console. */
ex.data._processing = undefined;
}
);
}
content.ent.push(ex);
return content;
}
/**
* Wraps drafty document into a simple formatting style.
* @memberof Drafty
* @static
*
* @param {Drafty|string} content - document or string to wrap into a style.
* @param {string} style - two-letter style to wrap into.
* @param {number} at - index where the style starts, default 0.
* @param {number} len - length of the form content, default all of it.
*
* @return {Drafty} updated document.
*/
Drafty.wrapInto = function(content, style, at, len) {
if (typeof content == 'string') {
content = {
txt: content
};
}
content.fmt = content.fmt || [];
content.fmt.push({
at: at || 0,
len: len || content.txt.length,
tp: style,
});
return content;
}
/**
* Wraps content into an interactive form.
* @memberof Drafty
* @static
*
* @param {Drafty|string} content - to wrap into a form.
* @param {number} at - index where the forms starts.
* @param {number} len - length of the form content.
*
* @return {Drafty} updated document.
*/
Drafty.wrapAsForm = function(content, at, len) {
return Drafty.wrapInto(content, 'FM', at, len);
}
/**
* Insert clickable button into Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty|string} content - Drafty document to insert button to or a string to be used as button text.
* @param {number} at - location where the button is inserted.
* @param {number} len - the length of the text to be used as button title.
* @param {string} name - the button. Client should return it to the server when the button is clicked.
* @param {string} actionType - the type of the button, one of 'url' or 'pub'.
* @param {string} actionValue - the value to return on click:
* @param {string} refUrl - the URL to go to when the 'url' button is clicked.
*
* @return {Drafty} updated document.
*/
Drafty.insertButton = function(content, at, len, name, actionType, actionValue, refUrl) {
if (typeof content == 'string') {
content = {
txt: content
};
}
if (!content || !content.txt || content.txt.length < at + len) {
return null;
}
if (len <= 0 || ['url', 'pub'].indexOf(actionType) == -1) {
return null;
}
// Ensure refUrl is a string.
if (actionType == 'url' && !refUrl) {
return null;
}
refUrl = '' + refUrl;
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: at | 0,
len: len,
key: content.ent.length
});
content.ent.push({
tp: 'BN',
data: {
act: actionType,
val: actionValue,
ref: refUrl,
name: name
}
});
return content;
}
/**
* Append clickable button to Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty|string} content - Drafty document to insert button to or a string to be used as button text.
* @param {string} title - the text to be used as button title.
* @param {string} name - the button. Client should return it to the server when the button is clicked.
* @param {string} actionType - the type of the button, one of 'url' or 'pub'.
* @param {string} actionValue - the value to return on click:
* @param {string} refUrl - the URL to go to when the 'url' button is clicked.
*
* @return {Drafty} updated document.
*/
Drafty.appendButton = function(content, title, name, actionType, actionValue, refUrl) {
content = content || {
txt: ''
};
const at = content.txt.length;
content.txt += title;
return Drafty.insertButton(content, at, title.length, name, actionType, actionValue, refUrl);
}
/**
* Attach a generic JS object. The object is attached as a json string.
* Intended for representing a form response.
*
* @memberof Drafty
* @static
*
* @param {Drafty} content - Drafty document to attach file to.
* @param {Object} data - data to convert to json string and attach.
* @returns {Drafty} the same document as <code>content</code>.
*/
Drafty.attachJSON = function(content, data) {
content = content || {
txt: ''
};
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: -1,
len: 0,
key: content.ent.length
});
content.ent.push({
tp: 'EX',
data: {
mime: JSON_MIME_TYPE,
val: data
}
});
return content;
}
/**
* Append line break to a Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty} content - Drafty document to append linebreak to.
* @returns {Drafty} the same document as <code>content</code>.
*/
Drafty.appendLineBreak = function(content) {
content = content || {
txt: ''
};
content.fmt = content.fmt || [];
content.fmt.push({
at: content.txt.length,
len: 1,
tp: 'BR'
});
content.txt += ' ';
return content;
}
/**
* Given Drafty document, convert it to HTML.
* No attempt is made to strip pre-existing html markup.
* This is potentially unsafe because <code>content.txt</code> may contain malicious HTML
* markup.
* @memberof Tinode.Drafty
* @static
*
* @param {Drafty} doc - document to convert.
*
* @returns {string} HTML-representation of content.
*/
Drafty.UNSAFE_toHTML = function(doc) {
const tree = draftyToTree(doc);
const htmlFormatter = function(type, data, values) {
const tag = DECORATORS[type];
let result = values ? values.join('') : '';
if (tag) {
result = tag.open(data) + result + tag.close(data);
}
return result;
};
return treeBottomUp(tree, htmlFormatter, 0);
}
/**
* Callback for applying custom formatting to a Drafty document.
* Called once for each style span.
* @memberof Drafty
* @static
*
* @callback Formatter
* @param {string} style - style code such as "ST" or "IM".
* @param {Object} data - entity's data.
* @param {Object} values - possibly styled subspans contained in this style span.
* @param {number} index - index of the element guaranteed to be unique.
*/
/**
* Convert Drafty document to a representation suitable for display.
* The <code>context</code> may expose a function <code>getFormatter(style)</code>. If it's available
* it will call it to obtain a <code>formatter</code> for a subtree of styles under the <code>style</code>.
* @memberof Drafty
* @static
*
* @param {Drafty|Object} content - Drafty document to transform.
* @param {Formatter} formatter - callback which formats individual elements.
* @param {Object} context - context provided to formatter as <code>this</code>.
*
* @return {Object} transformed object
*/
Drafty.format = function(original, formatter, context) {
return treeBottomUp(draftyToTree(original), formatter, 0, [], context);
}
/**
* Shorten Drafty document making the drafty text no longer than the limit.
* @memberof Drafty
* @static
*
* @param {Drafty|string} original - Drafty object to shorten.
* @param {number} limit - length in characrets to shorten to.
* @param {boolean} light - remove heavy data from entities.
* @returns new shortened Drafty object leaving the original intact.
*/
Drafty.shorten = function(original, limit, light) {
let tree = draftyToTree(original);
tree = shortenTree(tree, limit, '…');
if (tree && light) {
tree = lightEntity(tree);
}
return treeToDrafty({}, tree, []);
}
/**
* Transform Drafty doc for forwarding: strip leading @mention and any leading line breaks or whitespace.
* @memberof Drafty
* @static
*
* @param {Drafty|string} original - Drafty object to shorten.
* @returns converted Drafty object leaving the original intact.
*/
Drafty.forwardedContent = function(original) {
let tree = draftyToTree(original);
const rmMention = function(node) {
if (node.type == 'MN') {
if (!node.parent || !node.parent.type) {
return null;
}
}
return node;
}
// Strip leading mention.
tree = treeTopDown(tree, rmMention);
// Remove leading whitespace.
tree = lTrim(tree);
// Convert back to Drafty.
return treeToDrafty({}, tree, []);
}
/**
* Prepare Drafty doc for wrapping into QQ as a reply:
* - Replace forwarding mention with symbol '➦' and remove data (UID).
* - Remove quoted text completely.
* - Replace line breaks with spaces.
* - Strip entities of heavy content.
* - Move attachments to the end of the document.
* @memberof Drafty
* @static
*
* @param {Drafty|string} original - Drafty object to shorten.
* @param {number} limit - length in characters to shorten to.
* @returns converted Drafty object leaving the original intact.
*/
Drafty.replyContent = function(original, limit) {
const convMNnQQnBR = function(node) {
if (node.type == 'QQ') {
return null;
} else if (node.type == 'MN') {
if ((!node.parent || !node.parent.type) && (node.text || '').startsWith('➦')) {
node.text = '➦';
delete node.children;
delete node.data;
}
} else if (node.type == 'BR') {
node.text = ' ';
delete node.type;
delete node.children;
}
return node;
}
let tree = draftyToTree(original);
if (!tree) {
return original;
}
// Strip leading mention.
tree = treeTopDown(tree, convMNnQQnBR);
// Move attachments to the end of the doc.
tree = attachmentsToEnd(tree, MAX_PREVIEW_ATTACHMENTS);
// Shorten the doc.
tree = shortenTree(tree, limit, '…');
// Strip heavy elements except IM.data['val'] and VD.data['preview'] (have to keep them to generate previews later).
const filter = node => {
switch (node.type) {
case 'IM':
return ['val'];
case 'VD':
return ['preview'];
}
return null;
};
tree = lightEntity(tree, filter);
// Convert back to Drafty.
return treeToDrafty({}, tree, []);
}
/**
* Generate drafty preview:
* - Shorten the document.
* - Strip all heavy entity data leaving just inline styles and entity references.
* - Replace line breaks with spaces.
* - Replace content of QQ with a space.
* - Replace forwarding mention with symbol '➦'.
* move all attachments to the end of the document and make them visible.
* The <code>context</code> may expose a function <code>getFormatter(style)</code>. If it's available
* it will call it to obtain a <code>formatter</code> for a subtree of styles under the <code>style</code>.
* @memberof Drafty
* @static
*
* @param {Drafty|string} original - Drafty object to shorten.
* @param {number} limit - length in characters to shorten to.
* @param {boolean} forwarding - this a forwarding message preview.
* @returns new shortened Drafty object leaving the original intact.
*/
Drafty.preview = function(original, limit, forwarding) {
let tree = draftyToTree(original);
// Move attachments to the end.
tree = attachmentsToEnd(tree, MAX_PREVIEW_ATTACHMENTS);
// Convert leading mention to '➦' and replace QQ and BR with a space ' '.
const convMNnQQnBR = function(node) {
if (node.type == 'MN') {
if ((!node.parent || !node.parent.type) && (node.text || '').startsWith('➦')) {
node.text = '➦';
delete node.children;
}
} else if (node.type == 'QQ') {
node.text = ' ';
delete node.children;
} else if (node.type == 'BR') {
node.text = ' ';
delete node.children;
delete node.type;
}
return node;
}
tree = treeTopDown(tree, convMNnQQnBR);
tree = shortenTree(tree, limit, '…');
if (forwarding) {
// Keep some IM and VD data for preview.
const filter = {
IM: ['val'],
VD: ['preview']
};
tree = lightEntity(tree, node => {
return filter[node.type];
});
} else {
tree = lightEntity(tree);
}
// Convert back to Drafty.
return treeToDrafty({}, tree, []);
}
/**
* Given Drafty document, convert it to plain text.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to convert to plain text.
* @returns {string} plain-text representation of the drafty document.
*/
Drafty.toPlainText = function(content) {
return typeof content == 'string' ? content : content.txt;
}
/**
* Check if the document has no markup and no entities.
* @memberof Drafty
* @static
*
* @param {Drafty} content - content to check for presence of markup.
* @returns <code>true</code> is content is plain text, <code>false</code> otherwise.
*/
Drafty.isPlainText = function(content) {
return typeof content == 'string' || !(content.fmt || content.ent);
}
/**
* Convert document to plain text with markdown. All elements which cannot
* be represented in markdown are stripped.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to convert to plain text with markdown.
*/
Drafty.toMarkdown = function(content) {
let tree = draftyToTree(content);
const mdFormatter = function(type, _, values) {
const def = FORMAT_TAGS[type];
let result = (values ? values.join('') : '');
if (def) {
if (def.isVoid) {
result = def.md_tag || '';
} else if (def.md_tag) {
result = def.md_tag + result + def.md_tag;
}
}
return result;
};
return treeBottomUp(tree, mdFormatter, 0);
}
/**
* Checks if the object represets is a valid Drafty document.
* @memberof Drafty
* @static
*
* @param {Drafty} content - content to check for validity.
* @returns <code>true</code> is content is valid, <code>false</code> otherwise.
*/
Drafty.isValid = function(content) {
if (!content) {
return false;
}
const {
txt,
fmt,
ent
} = content;
if (!txt && txt !== '' && !fmt && !ent) {
return false;
}
const txt_type = typeof txt;
if (txt_type != 'string' && txt_type != 'undefined' && txt !== null) {
return false;
}
if (typeof fmt != 'undefined' && !Array.isArray(fmt) && fmt !== null) {
return false;
}
if (typeof ent != 'undefined' && !Array.isArray(ent) && ent !== null) {
return false;
}
return true;
}
/**
* Check if the drafty document has attachments: style EX and outside of normal rendering flow,
* i.e. <code>at = -1</code>.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to check for attachments.
* @returns <code>true</code> if there are attachments.
*/
Drafty.hasAttachments = function(content) {
if (!Array.isArray(content.fmt)) {
return false;
}
for (let i in content.fmt) {
const fmt = content.fmt[i];
if (fmt && fmt.at < 0) {
const ent = content.ent[fmt.key | 0];
return ent && ent.tp == 'EX' && ent.data;
}
}
return false;
}
/**
* Callback for enumerating entities in a Drafty document.
* Called once for each entity.
* @memberof Drafty
* @static
*
* @callback EntityCallback
* @param {Object} data entity data.
* @param {string} entity type.
* @param {number} index entity's index in `content.ent`.
*
* @return 'true-ish' to stop processing, 'false-ish' otherwise.
*/
/**
* Enumerate attachments: style EX and outside of normal rendering flow, i.e. <code>at = -1</code>.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to process for attachments.
* @param {EntityCallback} callback - callback to call for each attachment.
* @param {Object} context - value of "this" for callback.
*/
Drafty.attachments = function(content, callback, context) {
if (!Array.isArray(content.fmt)) {
return;
}
let count = 0;
for (let i in content.ent) {
let fmt = content.fmt[i];
if (fmt && fmt.at < 0) {
const ent = content.ent[fmt.key | 0];
if (ent && ent.tp == 'EX' && ent.data) {
if (callback.call(context, ent.data, count++, 'EX')) {
break;
}
}
}
};
}
/**
* Check if the drafty document has entities.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document to check for entities.
* @returns <code>true</code> if there are entities.
*/
Drafty.hasEntities = function(content) {
return content.ent && content.ent.length > 0;
}
/**
* Enumerate entities. Enumeration stops if callback returns 'true'.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document with entities to enumerate.
* @param {EntityCallback} callback - callback to call for each entity.
* @param {Object} context - value of "this" for callback.
*
*/
Drafty.entities = function(content, callback, context) {
if (content.ent && content.ent.length > 0) {
for (let i in content.ent) {
if (content.ent[i]) {
if (callback.call(context, content.ent[i].data, i, content.ent[i].tp)) {
break;
}
}
}
}
}
/**
* Callback for enumerating styles (inline formats) in a Drafty document.
* Called once for each style.
* @memberof Drafty
* @static
*
* @callback StyleCallback
* @param {string} tp - format type.
* @param {number} at - starting position of the format in text.
* @param {number} len - extent of the format in characters.
* @param {number} key - index of the entity if format is a reference.
* @param {number} index - style's index in `content.fmt`.
*
* @return 'true-ish' to stop processing, 'false-ish' otherwise.
*/
/**
* Enumerate styles (inline formats). Enumeration stops if callback returns 'true'.
* @memberof Drafty
* @static
*
* @param {Drafty} content - document with styles (formats) to enumerate.
* @param {StyleCallback} callback - callback to call for each format.
* @param {Object} context - value of "this" for callback.
*/
Drafty.styles = function(content, callback, context) {
if (content.fmt && content.fmt.length > 0) {
for (let i in content.fmt) {
const fmt = content.fmt[i];
if (fmt) {
if (callback.call(context, fmt.tp, fmt.at, fmt.len, fmt.key, i)) {
break;
}
}
}
}
}
/**
* Remove unrecognized fields from entity data
* @memberof Drafty
* @static
*
* @param {Drafty} content - document with entities to enumerate.
* @returns content.
*/
Drafty.sanitizeEntities = function(content) {
if (content && content.ent && content.ent.length > 0) {
for (let i in content.ent) {
const ent = content.ent[i];
if (ent && ent.data) {
const data = copyEntData(ent.data);
if (data) {
content.ent[i].data = data;
} else {
delete content.ent[i].data;
}
}
}
}
return content;
}
/**
* Given the entity, get URL which can be used for downloading
* entity data.
* @memberof Drafty
* @static
*
* @param {Object} entData - entity.data to get the URl from.
* @returns {string} URL to download entity data or <code>null</code>.
*/
Drafty.getDownloadUrl = function(entData) {
let url = null;
if (entData.mime != JSON_MIME_TYPE && entData.val) {
url = base64toObjectUrl(entData.val, entData.mime, Drafty.logger);
} else if (typeof entData.ref == 'string') {
url = entData.ref;
}
return url;
}
/**
* Check if the entity data is not ready for sending, such as being uploaded to the server.
* @memberof Drafty
* @static
*
* @param {Object} entity.data to get the URl from.
* @returns {boolean} true if upload is in progress, false otherwise.
*/
Drafty.isProcessing = function(entData) {
return !!entData._processing;
}
/**
* Given the entity, get URL which can be used for previewing
* the entity.
* @memberof Drafty
* @static
*
* @param {Object} entity.data to get the URl from.
*
* @returns {string} url for previewing or null if no such url is available.
*/
Drafty.getPreviewUrl = function(entData) {
return entData.val ? base64toObjectUrl(entData.val, entData.mime, Drafty.logger) : null;
}
/**
* Get approximate size of the entity.
* @memberof Drafty
* @static
*
* @param {Object} entData - entity.data to get the size for.
* @returns {number} size of entity data in bytes.
*/
Drafty.getEntitySize = function(entData) {
// Either size hint or length of value. The value is base64 encoded,
// the actual object size is smaller than the encoded length.
return entData.size ? entData.size : entData.val ? (entData.val.length * 0.75) | 0 : 0;
}
/**
* Get entity mime type.
* @memberof Drafty
* @static
*
* @param {Object} entData - entity.data to get the type for.
* @returns {string} mime type of entity.
*/
Drafty.getEntityMimeType = function(entData) {
return entData.mime || 'text/plain';
}
/**
* Get HTML tag for a given two-letter style name.
* @memberof Drafty
* @static
*
* @param {string} style - two-letter style, like ST or LN.
*
* @returns {string} HTML tag name if style is found, {code: undefined} if style is falsish or not found.
*/
Drafty.tagName = function(style) {
return FORMAT_TAGS[style] && FORMAT_TAGS[style].html_tag;
}
/**
* For a given data bundle generate an object with HTML attributes,
* for instance, given {url: "http://www.example.com/"} return
* {href: "http://www.example.com/"}
* @memberof Drafty
* @static
*
* @param {string} style - two-letter style to generate attributes for.
* @param {Object} data - data bundle to convert to attributes
*
* @returns {Object} object with HTML attributes.
*/
Drafty.attrValue = function(style, data) {
if (data && DECORATORS[style]) {
return DECORATORS[style].props(data);
}
return undefined;
}
/**
* Drafty MIME type.
* @memberof Drafty
* @static
*
* @returns {string} content-Type "text/x-drafty".
*/
Drafty.getContentType = function() {
return DRAFTY_MIME_TYPE;
}
// =================
// Utility methods.
// =================
// Take a string and defined earlier style spans, re-compose them into a tree where each leaf is
// 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.
function chunkify(line, start, end, spans) {
const chunks = [];
if (spans.length == 0) {
return [];
}
for (let i in spans) {
// Get the next chunk from the queue
const span = spans[i];
// Grab the initial unstyled chunk
if (span.at > start) {
chunks.push({
txt: line.slice(start, span.at)
});
}
// Grab the styled chunk. It may include subchunks.
const chunk = {
tp: span.tp
};
const chld = chunkify(line, span.at + 1, span.end, span.children);
if (chld.length > 0) {
chunk.children = chld;
} else {
chunk.txt = span.txt;
}
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({
txt: line.slice(start, end)
});
}
return chunks;
}
// Detect starts and ends of formatting spans. Unformatted spans are
// ignored at this stage.
function spannify(original, re_start, re_end, type) {
const result = [];
let index = 0;
let 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.
const start = re_start.exec(line);
if (start == null) {
break;
}
// Because javascript RegExp does not support lookbehind, the actual offset may not point
// at the markup character. Find it in the matched string.
let 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.
const end = re_end ? re_end.exec(line) : null;
if (end == null) {
break;
}
let 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 points to the beginning of 'line' within the 'original' string.
index = end_offset + 1;
result.push({
txt: original.slice(start_offset + 1, end_offset),
children: [],
at: start_offset,
end: end_offset,
tp: type
});
}
return result;
}
// Convert linear array or spans into a tree representation.
// Keep standalone and nested spans, throw away partially overlapping spans.
function toSpanTree(spans) {
if (spans.length == 0) {
return [];
}
const tree = [spans[0]];
let last = spans[0];
for (let 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].at > last.end) {
// Span is completely outside of the previous span.
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.
last.children.push(spans[i]);
}
// Span could partially overlap, ignoring it as invalid.
}
// Recursively rearrange the subnodes.
for (let i in tree) {
tree[i].children = toSpanTree(tree[i].children);
}
return tree;
}
// Convert drafty document to a tree.
function draftyToTree(doc) {
if (!doc) {
return null;
}
doc = (typeof doc == 'string') ? {
txt: doc
} : doc;
let {
txt,
fmt,
ent
} = doc;
txt = txt || '';
if (!Array.isArray(ent)) {
ent = [];
}
if (!Array.isArray(fmt) || fmt.length == 0) {
if (ent.length == 0) {
return {
text: txt
};
}
// Handle special case when all values in fmt are 0 and fmt therefore is skipped.
fmt = [{
at: 0,
len: 0,
key: 0
}];
}
// Sanitize spans.
const spans = [];
const attachments = [];
fmt.forEach((span) => {
if (!span || typeof span != 'object') {
return;
}
if (!['undefined', 'number'].includes(typeof span.at)) {
// Present, but non-numeric 'at'.
return;
}
if (!['undefined', 'number'].includes(typeof span.len)) {
// Present, but non-numeric 'len'.
return;
}
let at = span.at | 0;
let len = span.len | 0;
if (len < 0) {
// Invalid span length.
return;
}
let key = span.key || 0;
if (ent.length > 0 && (typeof key != 'number' || key < 0 || key >= ent.length)) {
// Invalid key value.
return;
}
if (at <= -1) {
// Attachment. Store attachments separately.
attachments.push({
start: -1,
end: 0,
key: key
});
return;
} else if (at + len > txt.length) {
// Span is out of bounds.
return;
}
if (!span.tp) {
if (ent.length > 0 && (typeof ent[key] == 'object')) {
spans.push({
start: at,
end: at + len,
key: key
});
}
} else {
spans.push({
type: span.tp,
start: at,
end: at + len
});
}
});
// Sort spans first by start index (asc) then by length (desc), then by weight.
spans.sort((a, b) => {
let diff = a.start - b.start;
if (diff != 0) {
return diff;
}
diff = b.end - a.end;
if (diff != 0) {
return diff;
}
return FMT_WEIGHT.indexOf(b.type) - FMT_WEIGHT.indexOf(a.type);
});
// Move attachments to the end of the list.
if (attachments.length > 0) {
spans.push(...attachments);
}
spans.forEach((span) => {
if (ent.length > 0 && !span.type && ent[span.key] && typeof ent[span.key] == 'object') {
span.type = ent[span.key].tp;
span.data = ent[span.key].data;
}
// Is type still undefined? Hide the invalid element!
if (!span.type) {
span.type = 'HD';
}
});
let tree = spansToTree({}, txt, 0, txt.length, spans);
// Flatten tree nodes.
const flatten = function(node) {
if (Array.isArray(node.children) && node.children.length == 1) {
// Unwrap.
const child = node.children[0];
if (!node.type) {
const parent = node.parent;
node = child;
node.parent = parent;
} else if (!child.type && !child.children) {
node.text = child.text;
delete node.children;
}
}
return node;
}
tree = treeTopDown(tree, flatten);
return tree;
}
// Add tree node to a parent tree.
function addNode(parent, n) {
if (!n) {
return parent;
}
if (!parent.children) {
parent.children = [];
}
// If text is present, move it to a subnode.
if (parent.text) {
parent.children.push({
text: parent.text,
parent: parent
});
delete parent.text;
}
n.parent = parent;
parent.children.push(n);
return parent;
}
// Returns a tree of nodes.
function spansToTree(parent, text, start, end, spans) {
if (!spans || spans.length == 0) {
if (start < end) {
addNode(parent, {
text: text.substring(start, end)
});
}
return parent;
}
// Process subspans.
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
if (span.start < 0 && span.type == 'EX') {
addNode(parent, {
type: span.type,
data: span.data,
key: span.key,
att: true
});
continue;
}
// Add un-styled range before the styled span starts.
if (start < span.start) {
addNode(parent, {
text: text.substring(start, span.start)
});
start = span.start;
}
// Get all spans which are within the current span.
const subspans = [];
while (i < spans.length - 1) {
const inner = spans[i + 1];
if (inner.start < 0) {
// Attachments are in the end. Stop.
break;
} else if (inner.start < span.end) {
if (inner.end <= span.end) {
const tag = FORMAT_TAGS[inner.tp] || {};
if (inner.start < inner.end || tag.isVoid) {
// Valid subspan: completely within the current span and
// either non-zero length or zero length is acceptable.
subspans.push(inner);
}
}
i++;
// Overlapping subspans are ignored.
} else {
// Past the end of the current span. Stop.
break;
}
}
addNode(parent, spansToTree({
type: span.type,
data: span.data,
key: span.key
}, text, start, span.end, subspans));
start = span.end;
}
// Add the last unformatted range.
if (start < end) {
addNode(parent, {
text: text.substring(start, end)
});
}
return parent;
}
// Append a tree to a Drafty doc.
function treeToDrafty(doc, tree, keymap) {
if (!tree) {
return doc;
}
doc.txt = doc.txt || '';
// Checkpoint to measure length of the current tree node.
const start = doc.txt.length;
if (tree.text) {
doc.txt += tree.text;
} else if (Array.isArray(tree.children)) {
tree.children.forEach((c) => {
treeToDrafty(doc, c, keymap);
});
}
if (tree.type) {
const len = doc.txt.length - start;
doc.fmt = doc.fmt || [];
if (Object.keys(tree.data || {}).length > 0) {
doc.ent = doc.ent || [];
const newKey = (typeof keymap[tree.key] == 'undefined') ? doc.ent.length : keymap[tree.key];
keymap[tree.key] = newKey;
doc.ent[newKey] = {
tp: tree.type,
data: tree.data
};
if (tree.att) {
// Attachment.
doc.fmt.push({
at: -1,
len: 0,
key: newKey
});
} else {
doc.fmt.push({
at: start,
len: len,
key: newKey
});
}
} else {
doc.fmt.push({
tp: tree.type,
at: start,
len: len
});
}
}
return doc;
}
// Traverse the tree top down transforming the nodes: apply transformer to every tree node.
function treeTopDown(src, transformer, context) {
if (!src) {
return null;
}
let dst = transformer.call(context, src);
if (!dst || !dst.children) {
return dst;
}
const children = [];
for (let i in dst.children) {
let n = dst.children[i];
if (n) {
n = treeTopDown(n, transformer, context);
if (n) {
children.push(n);
}
}
}
if (children.length == 0) {
dst.children = null;
} else {
dst.children = children;
}
return dst;
}
// Traverse the tree bottom-up: apply formatter to every node.
// The formatter must maintain its state through context.
function treeBottomUp(src, formatter, index, stack, context) {
if (!src) {
return null;
}
if (stack && src.type) {
stack.push(src.type);
}
let values = [];
for (let i in src.children) {
const n = treeBottomUp(src.children[i], formatter, i, stack, context);
if (n) {
values.push(n);
}
}
if (values.length == 0) {
if (src.text) {
values = [src.text];
} else {
values = null;
}
}
if (stack && src.type) {
stack.pop();
}
return formatter.call(context, src.type, src.data, values, index, stack);
}
// Clip tree to the provided limit.
function shortenTree(tree, limit, tail) {
if (!tree) {
return null;
}
if (tail) {
limit -= tail.length;
}
const shortener = function(node) {
if (limit <= -1) {
// Limit -1 means the doc was already clipped.
return null;
}
if (node.att) {
// Attachments are unchanged.
return node;
}
if (limit == 0) {
node.text = tail;
limit = -1;
} else if (node.text) {
const len = node.text.length;
if (len > limit) {
node.text = node.text.substring(0, limit) + tail;
limit = -1;
} else {
limit -= len;
}
}
return node;
}
return treeTopDown(tree, shortener);
}
// Strip heavy entities from a tree.
function lightEntity(tree, allow) {
const lightCopy = node => {
const data = copyEntData(node.data, true, allow ? allow(node) : null);
if (data) {
node.data = data;
} else {
delete node.data;
}
return node;
}
return treeTopDown(tree, lightCopy);
}
// Remove spaces and breaks on the left.
function lTrim(tree) {
if (tree.type == 'BR') {
tree = null;
} else if (tree.text) {
if (!tree.type) {
tree.text = tree.text.trimStart();
if (!tree.text) {
tree = null;
}
}
} else if (!tree.type && tree.children && tree.children.length > 0) {
const c = lTrim(tree.children[0]);
if (c) {
tree.children[0] = c;
} else {
tree.children.shift();
if (!tree.type && tree.children.length == 0) {
tree = null;
}
}
}
return tree;
}
// Move attachments to the end. Attachments must be at the top level, no need to traverse the tree.
function attachmentsToEnd(tree, limit) {
if (!tree) {
return null;
}
if (tree.att) {
tree.text = ' ';
delete tree.att;
delete tree.children;
} else if (tree.children) {
const attachments = [];
const children = [];
for (let i in tree.children) {
const c = tree.children[i];
if (c.att) {
if (attachments.length == limit) {
// Too many attachments to preview;
continue;
}
if (c.data['mime'] == JSON_MIME_TYPE) {
// JSON attachments are not shown in preview.
continue;
}
delete c.att;
delete c.children;
c.text = ' ';
attachments.push(c);
} else {
children.push(c);
}
}
tree.children = children.concat(attachments);
}
return tree;
}
// Get a list of entities from a text.
function extractEntities(line) {
let match;
let extracted = [];
ENTITY_TYPES.forEach((entity) => {
while ((match = entity.re.exec(line)) !== null) {
extracted.push({
offset: match['index'],
len: match[0].length,
unique: match[0],
data: entity.pack(match[0]),
type: entity.name
});
}
});
if (extracted.length == 0) {
return extracted;
}
// Remove entities detected inside other entities, like #hashtag in a URL.
extracted.sort((a, b) => {
return a.offset - b.offset;
});
let idx = -1;
extracted = extracted.filter((el) => {
const result = (el.offset > idx);
idx = el.offset + el.len;
return result;
});
return extracted;
}
// Convert the chunks into format suitable for serialization.
function draftify(chunks, startAt) {
let plain = '';
let ranges = [];
for (let i in chunks) {
const chunk = chunks[i];
if (!chunk.txt) {
const drafty = draftify(chunk.children, plain.length + startAt);
chunk.txt = drafty.txt;
ranges = ranges.concat(drafty.fmt);
}
if (chunk.tp) {
ranges.push({
at: plain.length + startAt,
len: chunk.txt.length,
tp: chunk.tp
});
}
plain += chunk.txt;
}
return {
txt: plain,
fmt: ranges
};
}
// Create a copy of entity data with (light=false) or without (light=true) the large payload.
// The array 'allow' contains a list of fields exempt from stripping.
function copyEntData(data, light, allow) {
if (data && Object.entries(data).length > 0) {
allow = allow || [];
const dc = {};
ALLOWED_ENT_FIELDS.forEach(key => {
if (data[key]) {
if (light && !allow.includes(key) &&
(typeof data[key] == 'string' || Array.isArray(data[key])) &&
data[key].length > MAX_PREVIEW_DATA_SIZE) {
return;
}
if (typeof data[key] == 'object') {
return;
}
dc[key] = data[key];
}
});
if (Object.entries(dc).length != 0) {
return dc;
}
}
return null;
}
if (typeof module != 'undefined') {
module.exports = Drafty;
}