@roeehrl/tinode-sdk 0.25.1-sqlite.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/drafty.js ADDED
@@ -0,0 +1,2758 @@
1
+ /**
2
+ * @copyright 2015-2024 Tinode LLC.
3
+ * @summary Minimally rich text representation and formatting for Tinode.
4
+ * @license Apache 2.0
5
+ *
6
+ * @file Basic parser and formatter for very simple text markup. Mostly targeted at
7
+ * mobile use cases similar to Telegram, WhatsApp, and FB Messenger.
8
+ *
9
+ * <p>Supports conversion of user keyboard input to formatted text:</p>
10
+ * <ul>
11
+ * <li>*abc* &rarr; <b>abc</b></li>
12
+ * <li>_abc_ &rarr; <i>abc</i></li>
13
+ * <li>~abc~ &rarr; <del>abc</del></li>
14
+ * <li>`abc` &rarr; <tt>abc</tt></li>
15
+ * </ul>
16
+ * Also supports forms and buttons.
17
+ *
18
+ * Nested formatting is supported, e.g. *abc _def_* -> <b>abc <i>def</i></b>
19
+ * URLs, @mentions, and #hashtags are extracted and converted into links.
20
+ * Forms and buttons can be added procedurally.
21
+ * JSON data representation is inspired by Draft.js raw formatting.
22
+ *
23
+ *
24
+ * @example
25
+ * Text:
26
+ * <pre>
27
+ * this is *bold*, `code` and _italic_, ~strike~
28
+ * combined *bold and _italic_*
29
+ * an url: https://www.example.com/abc#fragment and another _www.tinode.co_
30
+ * this is a @mention and a #hashtag in a string
31
+ * second #hashtag
32
+ * </pre>
33
+ *
34
+ * Sample JSON representation of the text above:
35
+ * {
36
+ * "txt": "this is bold, code and italic, strike combined bold and italic an url: https://www.example.com/abc#fragment " +
37
+ * "and another www.tinode.co this is a @mention and a #hashtag in a string second #hashtag",
38
+ * "fmt": [
39
+ * { "at":8, "len":4,"tp":"ST" },{ "at":14, "len":4, "tp":"CO" },{ "at":23, "len":6, "tp":"EM"},
40
+ * { "at":31, "len":6, "tp":"DL" },{ "tp":"BR", "len":1, "at":37 },{ "at":56, "len":6, "tp":"EM" },
41
+ * { "at":47, "len":15, "tp":"ST" },{ "tp":"BR", "len":1, "at":62 },{ "at":120, "len":13, "tp":"EM" },
42
+ * { "at":71, "len":36, "key":0 },{ "at":120, "len":13, "key":1 },{ "tp":"BR", "len":1, "at":133 },
43
+ * { "at":144, "len":8, "key":2 },{ "at":159, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":179 },
44
+ * { "at":187, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":195 }
45
+ * ],
46
+ * "ent": [
47
+ * { "tp":"LN", "data":{ "url":"https://www.example.com/abc#fragment" } },
48
+ * { "tp":"LN", "data":{ "url":"http://www.tinode.co" } },
49
+ * { "tp":"MN", "data":{ "val":"mention" } },
50
+ * { "tp":"HT", "data":{ "val":"hashtag" } }
51
+ * ]
52
+ * }
53
+ */
54
+
55
+ 'use strict';
56
+
57
+ // NOTE TO DEVELOPERS:
58
+ // Localizable strings should be double quoted "строка на другом языке",
59
+ // non-localizable strings should be single quoted 'non-localized'.
60
+
61
+ const MAX_FORM_ELEMENTS = 8;
62
+ const MAX_PREVIEW_ATTACHMENTS = 3;
63
+ const MAX_PREVIEW_DATA_SIZE = 64;
64
+ const DRAFTY_MIME_TYPE = 'text/x-drafty';
65
+ // Drafty form-response MIME type.
66
+ const DRAFTY_FR_MIME_TYPE = 'text/x-drafty-fr';
67
+ // Legacy Drafty form-response MIME type.
68
+ const DRAFTY_FR_MIME_TYPE_LEGACY = 'application/json'; // Remove in 2026.
69
+ const ALLOWED_ENT_FIELDS = ['act', 'height', 'duration', 'incoming', 'mime', 'name', 'premime', 'preref', 'preview',
70
+ 'ref', 'size', 'state', 'url', 'val', 'width'
71
+ ];
72
+
73
+ // Intl.Segmenter is not available in Firefox 124 and earlier. FF 125 with support for Intl.Segmenter
74
+ // was released on April 15, 2024. Polyfill is included in the top package (webapp).
75
+ const segmenter = new Intl.Segmenter();
76
+
77
+ // Regular expressions for parsing inline formats. Javascript does not support lookbehind,
78
+ // so it's a bit messy.
79
+ const INLINE_STYLES = [
80
+ // Strong = bold, *bold text*
81
+ {
82
+ name: 'ST',
83
+ start: /(?:^|[\W_])(\*)[^\s*]/,
84
+ end: /[^\s*](\*)(?=$|[\W_])/
85
+ },
86
+ // Emphesized = italic, _italic text_
87
+ {
88
+ name: 'EM',
89
+ start: /(?:^|\W)(_)[^\s_]/,
90
+ end: /[^\s_](_)(?=$|\W)/
91
+ },
92
+ // Deleted, ~strike this though~
93
+ {
94
+ name: 'DL',
95
+ start: /(?:^|[\W_])(~)[^\s~]/,
96
+ end: /[^\s~](~)(?=$|[\W_])/
97
+ },
98
+ // Code block `this is monospace`
99
+ {
100
+ name: 'CO',
101
+ start: /(?:^|\W)(`)[^`]/,
102
+ end: /[^`](`)(?=$|\W)/
103
+ }
104
+ ];
105
+
106
+ // Relative weights of formatting spans. Greater index in array means greater weight.
107
+ const FMT_WEIGHT = ['QQ'];
108
+
109
+ // RegExps for entity extraction (RF = reference)
110
+ const ENTITY_TYPES = [
111
+ // URLs
112
+ {
113
+ name: 'LN',
114
+ dataName: 'url',
115
+ pack: function(val) {
116
+ // Check if the protocol is specified, if not use http
117
+ if (!/^[a-z]+:\/\//i.test(val)) {
118
+ val = 'http://' + val;
119
+ }
120
+ return {
121
+ url: val
122
+ };
123
+ },
124
+ re: /(?:(?:https?|ftp):\/\/|www\.|ftp\.)[-A-Z0-9+&@#\/%=~_|$?!:,.]*[A-Z0-9+&@#\/%=~_|$]/ig
125
+ },
126
+ // Mentions @user (must be 2 or more characters)
127
+ {
128
+ name: 'MN',
129
+ dataName: 'val',
130
+ pack: function(val) {
131
+ return {
132
+ val: val.slice(1)
133
+ };
134
+ },
135
+ re: /\B@([\p{L}\p{N}][._\p{L}\p{N}]*[\p{L}\p{N}])/ug
136
+ },
137
+ // Hashtags #hashtag, like metion 2 or more characters.
138
+ {
139
+ name: 'HT',
140
+ dataName: 'val',
141
+ pack: function(val) {
142
+ return {
143
+ val: val.slice(1)
144
+ };
145
+ },
146
+ re: /\B#([\p{L}\p{N}][._\p{L}\p{N}]*[\p{L}\p{N}])/ug
147
+ }
148
+ ];
149
+
150
+ // HTML tag name suggestions
151
+ const FORMAT_TAGS = {
152
+ AU: {
153
+ html_tag: 'audio',
154
+ md_tag: undefined,
155
+ isVoid: false
156
+ },
157
+ BN: {
158
+ html_tag: 'button',
159
+ md_tag: undefined,
160
+ isVoid: false
161
+ },
162
+ BR: {
163
+ html_tag: 'br',
164
+ md_tag: '\n',
165
+ isVoid: true
166
+ },
167
+ CO: {
168
+ html_tag: 'tt',
169
+ md_tag: '`',
170
+ isVoid: false
171
+ },
172
+ DL: {
173
+ html_tag: 'del',
174
+ md_tag: '~',
175
+ isVoid: false
176
+ },
177
+ EM: {
178
+ html_tag: 'i',
179
+ md_tag: '_',
180
+ isVoid: false
181
+ },
182
+ EX: {
183
+ html_tag: '',
184
+ md_tag: undefined,
185
+ isVoid: true
186
+ },
187
+ FM: {
188
+ html_tag: 'div',
189
+ md_tag: undefined,
190
+ isVoid: false
191
+ },
192
+ HD: {
193
+ html_tag: '',
194
+ md_tag: undefined,
195
+ isVoid: false
196
+ },
197
+ HL: {
198
+ html_tag: 'span',
199
+ md_tag: undefined,
200
+ isVoid: false
201
+ },
202
+ HT: {
203
+ html_tag: 'a',
204
+ md_tag: undefined,
205
+ isVoid: false
206
+ },
207
+ IM: {
208
+ html_tag: 'img',
209
+ md_tag: undefined,
210
+ isVoid: false
211
+ },
212
+ LN: {
213
+ html_tag: 'a',
214
+ md_tag: undefined,
215
+ isVoid: false
216
+ },
217
+ MN: {
218
+ html_tag: 'a',
219
+ md_tag: undefined,
220
+ isVoid: false
221
+ },
222
+ RW: {
223
+ html_tag: 'div',
224
+ md_tag: undefined,
225
+ isVoid: false,
226
+ },
227
+ QQ: {
228
+ html_tag: 'div',
229
+ md_tag: undefined,
230
+ isVoid: false
231
+ },
232
+ ST: {
233
+ html_tag: 'b',
234
+ md_tag: '*',
235
+ isVoid: false
236
+ },
237
+ VC: {
238
+ html_tag: 'div',
239
+ md_tag: undefined,
240
+ isVoid: false
241
+ },
242
+ VD: {
243
+ html_tag: 'video',
244
+ md_tag: undefined,
245
+ isVoid: false
246
+ }
247
+ };
248
+
249
+ // Convert base64-encoded string into Blob.
250
+ function base64toObjectUrl(b64, contentType, logger) {
251
+ if (!b64) {
252
+ return null;
253
+ }
254
+
255
+ try {
256
+ const bin = atob(b64);
257
+ const length = bin.length;
258
+ const buf = new ArrayBuffer(length);
259
+ const arr = new Uint8Array(buf);
260
+ for (let i = 0; i < length; i++) {
261
+ arr[i] = bin.charCodeAt(i);
262
+ }
263
+
264
+ return URL.createObjectURL(new Blob([buf], {
265
+ type: contentType
266
+ }));
267
+ } catch (err) {
268
+ if (logger) {
269
+ logger("Drafty: failed to convert object.", err.message);
270
+ }
271
+ }
272
+
273
+ return null;
274
+ }
275
+
276
+ function base64toDataUrl(b64, contentType) {
277
+ if (!b64) {
278
+ return null;
279
+ }
280
+ contentType = contentType || 'image/jpeg';
281
+ return 'data:' + contentType + ';base64,' + b64;
282
+ }
283
+
284
+ // Helpers for converting Drafty to HTML.
285
+ const DECORATORS = {
286
+ // Visial styles
287
+ ST: {
288
+ open: _ => '<b>',
289
+ close: _ => '</b>'
290
+ },
291
+ EM: {
292
+ open: _ => '<i>',
293
+ close: _ => '</i>'
294
+ },
295
+ DL: {
296
+ open: _ => '<del>',
297
+ close: _ => '</del>'
298
+ },
299
+ CO: {
300
+ open: _ => '<tt>',
301
+ close: _ => '</tt>'
302
+ },
303
+ // Line break
304
+ BR: {
305
+ open: _ => '<br/>',
306
+ close: _ => ''
307
+ },
308
+ // Hidden element
309
+ HD: {
310
+ open: _ => '',
311
+ close: _ => ''
312
+ },
313
+ // Highlighted element.
314
+ HL: {
315
+ open: _ => '<span style="color:teal">',
316
+ close: _ => '</span>'
317
+ },
318
+ // Link (URL)
319
+ LN: {
320
+ open: (data) => {
321
+ return '<a href="' + data.url + '">';
322
+ },
323
+ close: _ => '</a>',
324
+ props: (data) => {
325
+ return data ? {
326
+ href: data.url,
327
+ target: '_blank'
328
+ } : null;
329
+ },
330
+ },
331
+ // Mention
332
+ MN: {
333
+ open: (data) => {
334
+ return '<a href="#' + data.val + '">';
335
+ },
336
+ close: _ => '</a>',
337
+ props: (data) => {
338
+ return data ? {
339
+ id: data.val
340
+ } : null;
341
+ },
342
+ },
343
+ // Hashtag
344
+ HT: {
345
+ open: (data) => {
346
+ return '<a href="#' + data.val + '">';
347
+ },
348
+ close: _ => '</a>',
349
+ props: (data) => {
350
+ return data ? {
351
+ id: data.val
352
+ } : null;
353
+ },
354
+ },
355
+ // Button
356
+ BN: {
357
+ open: _ => '<button>',
358
+ close: _ => '</button>',
359
+ props: (data) => {
360
+ return data ? {
361
+ 'data-act': data.act,
362
+ 'data-val': data.val,
363
+ 'data-name': data.name,
364
+ 'data-ref': data.ref
365
+ } : null;
366
+ },
367
+ },
368
+ // Audio recording
369
+ AU: {
370
+ open: (data) => {
371
+ const url = data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger);
372
+ return '<audio controls src="' + url + '">';
373
+ },
374
+ close: _ => '</audio>',
375
+ props: (data) => {
376
+ if (!data) return null;
377
+ return {
378
+ // Embedded data or external link.
379
+ src: data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
380
+ 'data-preload': data.ref ? 'metadata' : 'auto',
381
+ 'data-duration': data.duration,
382
+ 'data-name': data.name,
383
+ 'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0),
384
+ 'data-mime': data.mime,
385
+ };
386
+ }
387
+ },
388
+ // Image
389
+ IM: {
390
+ open: data => {
391
+ // Don't use data.ref for preview: it's a security risk.
392
+ const tmpPreviewUrl = base64toDataUrl(data._tempPreview, data.mime);
393
+ const previewUrl = base64toObjectUrl(data.val, data.mime, Drafty.logger);
394
+ const downloadUrl = data.ref || previewUrl;
395
+ return (data.name ? '<a href="' + downloadUrl + '" download="' + data.name + '">' : '') +
396
+ '<img src="' + (tmpPreviewUrl || previewUrl) + '"' +
397
+ (data.width ? ' width="' + data.width + '"' : '') +
398
+ (data.height ? ' height="' + data.height + '"' : '') + ' border="0" />';
399
+ },
400
+ close: data => {
401
+ return (data.name ? '</a>' : '');
402
+ },
403
+ props: data => {
404
+ if (!data) return null;
405
+ return {
406
+ // Temporary preview, or permanent preview, or external link.
407
+ src: base64toDataUrl(data._tempPreview, data.mime) ||
408
+ data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
409
+ title: data.name,
410
+ alt: data.name,
411
+ 'data-width': data.width,
412
+ 'data-height': data.height,
413
+ 'data-name': data.name,
414
+ 'data-size': data.ref ? (data.size | 0) : (data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0)),
415
+ 'data-mime': data.mime,
416
+ };
417
+ },
418
+ },
419
+ // Form - structured layout of elements.
420
+ FM: {
421
+ open: _ => '<div>',
422
+ close: _ => '</div>'
423
+ },
424
+ // Row: logic grouping of elements
425
+ RW: {
426
+ open: _ => '<div>',
427
+ close: _ => '</div>'
428
+ },
429
+ // Quoted block.
430
+ QQ: {
431
+ open: _ => '<div>',
432
+ close: _ => '</div>',
433
+ props: (data) => {
434
+ return data ? {} : null;
435
+ },
436
+ },
437
+ // Video call
438
+ VC: {
439
+ open: _ => '<div>',
440
+ close: _ => '</div>',
441
+ props: data => {
442
+ if (!data) return {};
443
+ return {
444
+ 'data-duration': data.duration,
445
+ 'data-state': data.state,
446
+ };
447
+ }
448
+ },
449
+ // Video.
450
+ VD: {
451
+ open: data => {
452
+ const tmpPreviewUrl = base64toDataUrl(data._tempPreview, data.mime);
453
+ const previewUrl = data.ref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger);
454
+ return '<img src="' + (tmpPreviewUrl || previewUrl) + '"' +
455
+ (data.width ? ' width="' + data.width + '"' : '') +
456
+ (data.height ? ' height="' + data.height + '"' : '') + ' border="0" />';
457
+ },
458
+ close: _ => '',
459
+ props: data => {
460
+ if (!data) return null;
461
+ const poster = data.preref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger);
462
+ return {
463
+ // Embedded data or external link.
464
+ src: poster,
465
+ 'data-src': data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
466
+ 'data-width': data.width,
467
+ 'data-height': data.height,
468
+ 'data-preload': data.ref ? 'metadata' : 'auto',
469
+ 'data-preview': poster,
470
+ 'data-duration': data.duration | 0,
471
+ 'data-name': data.name,
472
+ 'data-size': data.ref ? (data.size | 0) : (data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0)),
473
+ 'data-mime': data.mime,
474
+ };
475
+ }
476
+ },
477
+ };
478
+
479
+ /**
480
+ * The main object which performs all the formatting actions.
481
+ * @class Drafty
482
+ * @constructor
483
+ */
484
+ const Drafty = function() {
485
+ this.txt = '';
486
+ this.fmt = [];
487
+ this.ent = [];
488
+ }
489
+
490
+ /**
491
+ * Initialize Drafty document to a plain text string.
492
+ *
493
+ * @param {string} plainText - string to use as Drafty content.
494
+ *
495
+ * @returns new Drafty document or null is plainText is not a string or undefined.
496
+ */
497
+ Drafty.init = function(plainText) {
498
+ if (typeof plainText == 'undefined') {
499
+ plainText = '';
500
+ } else if (typeof plainText != 'string') {
501
+ return null;
502
+ }
503
+
504
+ return {
505
+ txt: plainText
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Parse plain text into Drafty document.
511
+ * @memberof Drafty
512
+ * @static
513
+ *
514
+ * @param {string} content - plain-text content to parse.
515
+ * @return {Drafty} parsed document or null if the source is not plain text.
516
+ */
517
+ Drafty.parse = function(content) {
518
+ // Make sure we are parsing strings only.
519
+ if (typeof content != 'string') {
520
+ return null;
521
+ }
522
+
523
+ // Split text into lines. It makes further processing easier.
524
+ const lines = content.split(/\r?\n/);
525
+
526
+ // Holds entities referenced from text
527
+ const entityMap = [];
528
+ const entityIndex = {};
529
+
530
+ // Processing lines one by one, hold intermediate result in blx.
531
+ const blx = [];
532
+ lines.forEach((line) => {
533
+ let spans = [];
534
+ let entities;
535
+
536
+ // Find formatted spans in the string.
537
+ // Try to match each style.
538
+ INLINE_STYLES.forEach((tag) => {
539
+ // Each style could be matched multiple times.
540
+ spans = spans.concat(spannify(line, tag.start, tag.end, tag.name));
541
+ });
542
+
543
+ let block;
544
+ if (spans.length == 0) {
545
+ block = {
546
+ txt: line
547
+ };
548
+ } else {
549
+ // Sort spans by style occurence early -> late, then by length: first long then short.
550
+ spans.sort((a, b) => {
551
+ const diff = a.at - b.at;
552
+ return diff != 0 ? diff : b.end - a.end;
553
+ });
554
+
555
+ // Convert an array of possibly overlapping spans into a tree.
556
+ spans = toSpanTree(spans);
557
+
558
+ // Build a tree representation of the entire string, not
559
+ // just the formatted parts.
560
+ const chunks = chunkify(line, 0, line.length, spans);
561
+
562
+ const drafty = draftify(chunks, 0);
563
+
564
+ block = {
565
+ txt: drafty.txt,
566
+ fmt: drafty.fmt
567
+ };
568
+ }
569
+
570
+ // Extract entities from the cleaned up string.
571
+ entities = extractEntities(block.txt);
572
+ if (entities.length > 0) {
573
+ const ranges = [];
574
+ for (let i in entities) {
575
+ // {offset: match['index'], unique: match[0], len: match[0].length, data: ent.packer(), type: ent.name}
576
+ const entity = entities[i];
577
+ let index = entityIndex[entity.unique];
578
+ if (!index) {
579
+ index = entityMap.length;
580
+ entityIndex[entity.unique] = index;
581
+ entityMap.push({
582
+ tp: entity.type,
583
+ data: entity.data
584
+ });
585
+ }
586
+ ranges.push({
587
+ at: entity.offset,
588
+ len: entity.len,
589
+ key: index
590
+ });
591
+ }
592
+ block.ent = ranges;
593
+ }
594
+
595
+ blx.push(block);
596
+ });
597
+
598
+ const result = {
599
+ txt: ''
600
+ };
601
+
602
+ // Merge lines and save line breaks as BR inline formatting.
603
+ if (blx.length > 0) {
604
+ result.txt = blx[0].txt;
605
+ result.fmt = (blx[0].fmt || []).concat(blx[0].ent || []);
606
+
607
+ if (result.fmt.length) {
608
+ const segments = segmenter.segment(result.txt);
609
+ for (const ele of result.fmt) {
610
+ ({
611
+ at: ele.at,
612
+ len: ele.len
613
+ } =
614
+ toGraphemeValues(ele, segments, result.txt));
615
+ }
616
+ }
617
+
618
+ for (let i = 1; i < blx.length; i++) {
619
+ const block = blx[i];
620
+ const offset = stringToGraphemes(result.txt).length + 1;
621
+
622
+ result.fmt.push({
623
+ tp: 'BR',
624
+ len: 1,
625
+ at: offset - 1
626
+ });
627
+
628
+ let segments = {};
629
+
630
+ result.txt += ' ' + block.txt;
631
+ if (block.fmt) {
632
+ segments = segmenter.segment(block.txt);
633
+ result.fmt = result.fmt.concat(
634
+ block.fmt.map((s) => {
635
+ const {
636
+ at: correctAt,
637
+ len: correctLen
638
+ } =
639
+ toGraphemeValues(s, segments, block.txt);
640
+ s.at = correctAt + offset;
641
+ s.len = correctLen;
642
+ return s;
643
+ })
644
+ );
645
+ }
646
+ if (block.ent) {
647
+ if (isEmptyObject(segments)) {
648
+ segments = segmenter.segment(block.txt);
649
+ }
650
+ result.fmt = result.fmt.concat(
651
+ block.ent.map((s) => {
652
+ const {
653
+ at: correctAt,
654
+ len: correctLen
655
+ } =
656
+ toGraphemeValues(s, segments, block.txt);
657
+ s.at = correctAt + offset;
658
+ s.len = correctLen;
659
+ return s;
660
+ })
661
+ );
662
+ }
663
+ }
664
+
665
+ if (result.fmt.length == 0) {
666
+ delete result.fmt;
667
+ }
668
+
669
+ if (entityMap.length > 0) {
670
+ result.ent = entityMap;
671
+ }
672
+ }
673
+ return result;
674
+ }
675
+
676
+ /**
677
+ * Append one Drafty document to another.
678
+ *
679
+ * @param {Drafty} first - Drafty document to append to.
680
+ * @param {Drafty|string} second - Drafty document or string being appended.
681
+ *
682
+ * @return {Drafty} first document with the second appended to it.
683
+ */
684
+ Drafty.append = function(first, second) {
685
+ if (!first) {
686
+ return second;
687
+ }
688
+ if (!second) {
689
+ return first;
690
+ }
691
+
692
+ first.txt = first.txt || '';
693
+ const len = stringToGraphemes(first.txt).length;
694
+
695
+ if (typeof second == 'string') {
696
+ first.txt += second;
697
+ } else if (second.txt) {
698
+ first.txt += second.txt;
699
+ }
700
+
701
+ if (Array.isArray(second.fmt)) {
702
+ first.fmt = first.fmt || [];
703
+ if (Array.isArray(second.ent)) {
704
+ first.ent = first.ent || [];
705
+ }
706
+ second.fmt.forEach(src => {
707
+ const fmt = {
708
+ at: (src.at | 0) + len,
709
+ len: src.len | 0
710
+ };
711
+ // Special case for the outside of the normal rendering flow styles.
712
+ if (src.at == -1) {
713
+ fmt.at = -1;
714
+ fmt.len = 0;
715
+ }
716
+ if (src.tp) {
717
+ fmt.tp = src.tp;
718
+ } else {
719
+ fmt.key = first.ent.length;
720
+ first.ent.push(second.ent[src.key || 0]);
721
+ }
722
+ first.fmt.push(fmt);
723
+ });
724
+ }
725
+
726
+ return first;
727
+ }
728
+
729
+ /**
730
+ * Description of an image to attach.
731
+ * @typedef {Object} ImageDesc
732
+ * @memberof Drafty
733
+ *
734
+ * @property {string} mime - mime-type of the image, e.g. "image/png".
735
+ * @property {string} refurl - reference to the content. Could be null/undefined.
736
+ * @property {string} bits - base64-encoded image content. Could be null/undefined.
737
+ * @property {string} preview - base64-encoded thumbnail of the image.
738
+ * @property {integer} width - width of the image.
739
+ * @property {integer} height - height of the image.
740
+ * @property {string} filename - file name suggestion for downloading the image.
741
+ * @property {integer} size - size of the image in bytes. Treat is as an untrusted hint.
742
+ * @property {string} _tempPreview - base64-encoded image preview used during upload process; not serializable.
743
+ * @property {Promise} urlPromise - Promise which returns content URL when resolved.
744
+ */
745
+
746
+ /**
747
+ * Insert inline image into Drafty document.
748
+ * @memberof Drafty
749
+ * @static
750
+ *
751
+ * @param {Drafty} content - document to add image to.
752
+ * @param {integer} at - index where the object is inserted. The length of the image is always 1.
753
+ * @param {ImageDesc} imageDesc - object with image paramenets and data.
754
+ *
755
+ * @return {Drafty} updated document.
756
+ */
757
+ Drafty.insertImage = function(content, at, imageDesc) {
758
+ content = content || {
759
+ txt: ' '
760
+ };
761
+ content.ent = content.ent || [];
762
+ content.fmt = content.fmt || [];
763
+
764
+ content.fmt.push({
765
+ at: at | 0,
766
+ len: 1,
767
+ key: content.ent.length
768
+ });
769
+
770
+ const ex = {
771
+ tp: 'IM',
772
+ data: {
773
+ mime: imageDesc.mime,
774
+ ref: imageDesc.refurl,
775
+ val: imageDesc.bits || imageDesc.preview,
776
+ width: imageDesc.width,
777
+ height: imageDesc.height,
778
+ name: imageDesc.filename,
779
+ size: imageDesc.size | 0,
780
+ }
781
+ };
782
+
783
+ if (imageDesc.urlPromise) {
784
+ ex.data._tempPreview = imageDesc._tempPreview;
785
+ ex.data._processing = true;
786
+ imageDesc.urlPromise.then(
787
+ url => {
788
+ ex.data.ref = url;
789
+ ex.data._tempPreview = undefined;
790
+ ex.data._processing = undefined;
791
+ },
792
+ _ => {
793
+ // Catch the error, otherwise it will appear in the console.
794
+ ex.data._processing = undefined;
795
+ }
796
+ );
797
+ }
798
+
799
+ content.ent.push(ex);
800
+
801
+ return content;
802
+ }
803
+
804
+ /**
805
+ * Description of a video to attach.
806
+ * @typedef {Object} VideoDesc
807
+ * @memberof Drafty
808
+ *
809
+ * @property {string} mime - mime-type of the video, e.g. "video/mpeg".
810
+ * @property {string} refurl - reference to the content. Could be null/undefined.
811
+ * @property {string} bits - in-band base64-encoded image data. Could be null/undefined.
812
+ * @property {string} preview - base64-encoded screencapture from the video. Could be null/undefined.
813
+ * @property {string} preref - reference to screencapture from the video. Could be null/undefined.
814
+ * @property {integer} width - width of the video.
815
+ * @property {integer} height - height of the video.
816
+ * @property {integer} duration - duration of the video.
817
+ * @property {string} filename - file name suggestion for downloading the video.
818
+ * @property {integer} size - size of the video in bytes. Treat is as an untrusted hint.
819
+ * @property {string} _tempPreview - base64-encoded screencapture used during upload process; not serializable.
820
+ * @property {Promise} urlPromise - array of two promises, which return URLs of video and preview uploads correspondingly
821
+ * (either could be null).
822
+ */
823
+
824
+ /**
825
+ * Insert inline image into Drafty document.
826
+ * @memberof Drafty
827
+ * @static
828
+ *
829
+ * @param {Drafty} content - document to add video to.
830
+ * @param {integer} at - index where the object is inserted. The length of the video is always 1.
831
+ * @param {VideoDesc} videoDesc - object with video paramenets and data.
832
+ *
833
+ * @return {Drafty} updated document.
834
+ */
835
+ Drafty.insertVideo = function(content, at, videoDesc) {
836
+ content = content || {
837
+ txt: ' '
838
+ };
839
+ content.ent = content.ent || [];
840
+ content.fmt = content.fmt || [];
841
+
842
+ content.fmt.push({
843
+ at: at | 0,
844
+ len: 1,
845
+ key: content.ent.length
846
+ });
847
+
848
+ const ex = {
849
+ tp: 'VD',
850
+ data: {
851
+ mime: videoDesc.mime,
852
+ ref: videoDesc.refurl,
853
+ val: videoDesc.bits,
854
+ preref: videoDesc.preref,
855
+ preview: videoDesc.preview,
856
+ width: videoDesc.width,
857
+ height: videoDesc.height,
858
+ duration: videoDesc.duration | 0,
859
+ name: videoDesc.filename,
860
+ size: videoDesc.size | 0,
861
+ }
862
+ };
863
+
864
+ if (videoDesc.urlPromise) {
865
+ ex.data._tempPreview = videoDesc._tempPreview;
866
+ ex.data._processing = true;
867
+ videoDesc.urlPromise.then(
868
+ urls => {
869
+ ex.data.ref = urls[0];
870
+ ex.data.preref = urls[1];
871
+ ex.data._tempPreview = undefined;
872
+ ex.data._processing = undefined;
873
+ },
874
+ _ => {
875
+ // Catch the error, otherwise it will appear in the console.
876
+ ex.data._processing = undefined;
877
+ }
878
+ );
879
+ }
880
+
881
+ content.ent.push(ex);
882
+
883
+ return content;
884
+ }
885
+
886
+ /**
887
+ * Description of an audio recording to attach.
888
+ * @typedef {Object} AudioDesc
889
+ * @memberof Drafty
890
+ *
891
+ * @property {string} mime - mime-type of the audio, e.g. "audio/ogg".
892
+ * @property {string} refurl - reference to the content. Could be null/undefined.
893
+ * @property {string} bits - base64-encoded audio content. Could be null/undefined.
894
+ * @property {integer} duration - duration of the record in milliseconds.
895
+ * @property {string} preview - base64 encoded short array of amplitude values 0..100.
896
+ * @property {string} filename - file name suggestion for downloading the audio.
897
+ * @property {integer} size - size of the recording in bytes. Treat is as an untrusted hint.
898
+ * @property {Promise} urlPromise - Promise which returns content URL when resolved.
899
+ */
900
+
901
+ /**
902
+ * Insert audio recording into Drafty document.
903
+ * @memberof Drafty
904
+ * @static
905
+ *
906
+ * @param {Drafty} content - document to add audio record to.
907
+ * @param {integer} at - index where the object is inserted. The length of the record is always 1.
908
+ * @param {AudioDesc} audioDesc - object with the audio paramenets and data.
909
+ *
910
+ * @return {Drafty} updated document.
911
+ */
912
+ Drafty.insertAudio = function(content, at, audioDesc) {
913
+ content = content || {
914
+ txt: ' '
915
+ };
916
+ content.ent = content.ent || [];
917
+ content.fmt = content.fmt || [];
918
+
919
+ content.fmt.push({
920
+ at: at | 0,
921
+ len: 1,
922
+ key: content.ent.length
923
+ });
924
+
925
+ const ex = {
926
+ tp: 'AU',
927
+ data: {
928
+ mime: audioDesc.mime,
929
+ val: audioDesc.bits,
930
+ duration: audioDesc.duration | 0,
931
+ preview: audioDesc.preview,
932
+ name: audioDesc.filename,
933
+ size: audioDesc.size | 0,
934
+ ref: audioDesc.refurl
935
+ }
936
+ };
937
+
938
+ if (audioDesc.urlPromise) {
939
+ ex.data._processing = true;
940
+ audioDesc.urlPromise.then(
941
+ url => {
942
+ ex.data.ref = url;
943
+ ex.data._processing = undefined;
944
+ },
945
+ _ => {
946
+ // Catch the error, otherwise it will appear in the console.
947
+ ex.data._processing = undefined;
948
+ }
949
+ );
950
+ }
951
+
952
+ content.ent.push(ex);
953
+
954
+ return content;
955
+ }
956
+
957
+ /**
958
+ * Create a (self-contained) video call Drafty document.
959
+ * @memberof Drafty
960
+ * @static
961
+ * @param {boolean} audioOnly <code>true</code> if the call is initially audio-only.
962
+ * @returns Video Call drafty document.
963
+ */
964
+ Drafty.videoCall = function(audioOnly) {
965
+ const content = {
966
+ txt: ' ',
967
+ fmt: [{
968
+ at: 0,
969
+ len: 1,
970
+ key: 0
971
+ }],
972
+ ent: [{
973
+ tp: 'VC',
974
+ data: {
975
+ aonly: audioOnly
976
+ },
977
+ }]
978
+ };
979
+ return content;
980
+ }
981
+
982
+ /**
983
+ * Update video call (VC) entity with the new status and duration.
984
+ * @memberof Drafty
985
+ * @static
986
+ *
987
+ * @param {Drafty} content - VC document to update.
988
+ * @param {object} params - new video call parameters.
989
+ * @param {string} params.state - state of video call.
990
+ * @param {number} params.duration - duration of the video call in milliseconds.
991
+ *
992
+ * @returns the same document with update applied.
993
+ */
994
+ Drafty.updateVideoCall = function(content, params) {
995
+ // The video element could be just a format or a format + entity.
996
+ // Must ensure it's the latter first.
997
+ const fmt = ((content || {}).fmt || [])[0];
998
+ if (!fmt) {
999
+ // Unrecognized content.
1000
+ return content;
1001
+ }
1002
+
1003
+ let ent;
1004
+ if (fmt.tp == 'VC') {
1005
+ // Just a format, convert to format + entity.
1006
+ delete fmt.tp;
1007
+ fmt.key = 0;
1008
+ ent = {
1009
+ tp: 'VC'
1010
+ };
1011
+ content.ent = [ent];
1012
+ } else {
1013
+ ent = (content.ent || [])[fmt.key | 0];
1014
+ if (!ent || ent.tp != 'VC') {
1015
+ // Not a VC entity.
1016
+ return content;
1017
+ }
1018
+ }
1019
+ ent.data = ent.data || {};
1020
+ Object.assign(ent.data, params);
1021
+ return content;
1022
+ }
1023
+
1024
+ /**
1025
+ * Create a quote to Drafty document.
1026
+ * @memberof Drafty
1027
+ * @static
1028
+ *
1029
+ * @param {string} header - Quote header (title, etc.).
1030
+ * @param {string} uid - UID of the author to mention.
1031
+ * @param {Drafty} body - Body of the quoted message.
1032
+ *
1033
+ * @returns Reply quote Drafty doc with the quote formatting.
1034
+ */
1035
+ Drafty.quote = function(header, uid, body) {
1036
+ const quote = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, uid)), body);
1037
+
1038
+ // Wrap into a quote.
1039
+ quote.fmt.push({
1040
+ at: 0,
1041
+ len: stringToGraphemes(quote.txt).length,
1042
+ tp: 'QQ'
1043
+ });
1044
+
1045
+ return quote;
1046
+ }
1047
+
1048
+ /**
1049
+ * Create a Drafty document with a mention.
1050
+ *
1051
+ * @param {string} name - mentioned name.
1052
+ * @param {string} uid - mentioned user ID.
1053
+ *
1054
+ * @returns {Drafty} document with the mention.
1055
+ */
1056
+ Drafty.mention = function(name, uid) {
1057
+ return {
1058
+ txt: name || '',
1059
+ fmt: [{
1060
+ at: 0,
1061
+ len: stringToGraphemes(name || '').length,
1062
+ key: 0
1063
+ }],
1064
+ ent: [{
1065
+ tp: 'MN',
1066
+ data: {
1067
+ val: uid
1068
+ }
1069
+ }]
1070
+ };
1071
+ }
1072
+
1073
+ /**
1074
+ * Append a link to a Drafty document.
1075
+ *
1076
+ * @param {Drafty} content - Drafty document to append link to.
1077
+ * @param {Object} linkData - Link info in format <code>{txt: 'ankor text', url: 'http://...'}</code>.
1078
+ *
1079
+ * @returns {Drafty} the same document as <code>content</code>.
1080
+ */
1081
+ Drafty.appendLink = function(content, linkData) {
1082
+ content = content || {
1083
+ txt: ''
1084
+ };
1085
+
1086
+ content.ent = content.ent || [];
1087
+ content.fmt = content.fmt || [];
1088
+
1089
+ content.fmt.push({
1090
+ at: content.txt.length,
1091
+ len: linkData.txt.length,
1092
+ key: content.ent.length
1093
+ });
1094
+ content.txt += linkData.txt;
1095
+
1096
+ const ex = {
1097
+ tp: 'LN',
1098
+ data: {
1099
+ url: linkData.url
1100
+ }
1101
+ }
1102
+ content.ent.push(ex);
1103
+
1104
+ return content;
1105
+ }
1106
+
1107
+ /**
1108
+ * Append image to Drafty document.
1109
+ * @memberof Drafty
1110
+ * @static
1111
+ *
1112
+ * @param {Drafty} content - document to add image to.
1113
+ * @param {ImageDesc} imageDesc - object with image paramenets.
1114
+ *
1115
+ * @return {Drafty} updated document.
1116
+ */
1117
+ Drafty.appendImage = function(content, imageDesc) {
1118
+ content = content || {
1119
+ txt: ''
1120
+ };
1121
+ content.txt += ' ';
1122
+ return Drafty.insertImage(content, content.txt.length - 1, imageDesc);
1123
+ }
1124
+
1125
+ /**
1126
+ * Append audio recodring to Drafty document.
1127
+ * @memberof Drafty
1128
+ * @static
1129
+ *
1130
+ * @param {Drafty} content - document to add recording to.
1131
+ * @param {AudioDesc} audioDesc - object with audio data.
1132
+ *
1133
+ * @return {Drafty} updated document.
1134
+ */
1135
+ Drafty.appendAudio = function(content, audioDesc) {
1136
+ content = content || {
1137
+ txt: ''
1138
+ };
1139
+ content.txt += ' ';
1140
+ return Drafty.insertAudio(content, content.txt.length - 1, audioDesc);
1141
+ }
1142
+
1143
+ /**
1144
+ * Description of a file to attach.
1145
+ * @typedef {Object} AttachmentDesc
1146
+ * @memberof Drafty
1147
+ *
1148
+ * @property {string} mime - mime-type of the attachment, e.g. "application/octet-stream"
1149
+ * @property {string} data - base64-encoded in-band content of small attachments. Could be null/undefined.
1150
+ * @property {string} filename - file name suggestion for downloading the attachment.
1151
+ * @property {integer} size - size of the file in bytes. Treat is as an untrusted hint.
1152
+ * @property {string} refurl - reference to the out-of-band content. Could be null/undefined.
1153
+ * @property {Promise} urlPromise - Promise which returns content URL when resolved.
1154
+ */
1155
+
1156
+ /**
1157
+ * Attach file to Drafty content. Either as a blob or as a reference.
1158
+ * @memberof Drafty
1159
+ * @static
1160
+ *
1161
+ * @param {Drafty} content - document to attach file to.
1162
+ * @param {AttachmentDesc} object - containing attachment description and data.
1163
+ *
1164
+ * @return {Drafty} updated document.
1165
+ */
1166
+ Drafty.attachFile = function(content, attachmentDesc) {
1167
+ content = content || {
1168
+ txt: ''
1169
+ };
1170
+
1171
+ content.ent = content.ent || [];
1172
+ content.fmt = content.fmt || [];
1173
+
1174
+ content.fmt.push({
1175
+ at: -1,
1176
+ len: 0,
1177
+ key: content.ent.length
1178
+ });
1179
+
1180
+ const ex = {
1181
+ tp: 'EX',
1182
+ data: {
1183
+ mime: attachmentDesc.mime,
1184
+ val: attachmentDesc.data,
1185
+ name: attachmentDesc.filename,
1186
+ ref: attachmentDesc.refurl,
1187
+ size: attachmentDesc.size | 0
1188
+ }
1189
+ }
1190
+ if (attachmentDesc.urlPromise) {
1191
+ ex.data._processing = true;
1192
+ attachmentDesc.urlPromise.then(
1193
+ url => {
1194
+ ex.data.ref = url;
1195
+ ex.data._processing = undefined;
1196
+ },
1197
+ _ => {
1198
+ /* catch the error, otherwise it will appear in the console. */
1199
+ ex.data._processing = undefined;
1200
+ }
1201
+ );
1202
+ }
1203
+ content.ent.push(ex);
1204
+
1205
+ return content;
1206
+ }
1207
+
1208
+ /**
1209
+ * Wraps drafty document into a simple formatting style.
1210
+ * @memberof Drafty
1211
+ * @static
1212
+ *
1213
+ * @param {Drafty|string} content - document or string to wrap into a style.
1214
+ * @param {string} style - two-letter style to wrap into.
1215
+ * @param {number} at - index where the style starts, default 0.
1216
+ * @param {number} len - length of the form content, default all of it.
1217
+ *
1218
+ * @return {Drafty} updated document.
1219
+ */
1220
+ Drafty.wrapInto = function(content, style, at, len) {
1221
+ if (typeof content == 'string') {
1222
+ content = {
1223
+ txt: content
1224
+ };
1225
+ }
1226
+ content.fmt = content.fmt || [];
1227
+
1228
+ content.fmt.push({
1229
+ at: at || 0,
1230
+ len: len || content.txt.length,
1231
+ tp: style,
1232
+ });
1233
+
1234
+ return content;
1235
+ }
1236
+
1237
+ /**
1238
+ * Wraps content into an interactive form.
1239
+ * @memberof Drafty
1240
+ * @static
1241
+ *
1242
+ * @param {Drafty|string} content - to wrap into a form.
1243
+ * @param {number} at - index where the forms starts.
1244
+ * @param {number} len - length of the form content.
1245
+ *
1246
+ * @return {Drafty} updated document.
1247
+ */
1248
+ Drafty.wrapAsForm = function(content, at, len) {
1249
+ return Drafty.wrapInto(content, 'FM', at, len);
1250
+ }
1251
+
1252
+ /**
1253
+ * Insert clickable button into Drafty document.
1254
+ * @memberof Drafty
1255
+ * @static
1256
+ *
1257
+ * @param {Drafty|string} content - Drafty document to insert button to or a string to be used as button text.
1258
+ * @param {number} at - location where the button is inserted.
1259
+ * @param {number} len - the length of the text to be used as button title.
1260
+ * @param {string} name - the button. Client should return it to the server when the button is clicked.
1261
+ * @param {string} actionType - the type of the button, one of 'url' or 'pub'.
1262
+ * @param {string} actionValue - the value to return on click:
1263
+ * @param {string} refUrl - the URL to go to when the 'url' button is clicked.
1264
+ *
1265
+ * @return {Drafty} updated document.
1266
+ */
1267
+ Drafty.insertButton = function(content, at, len, name, actionType, actionValue, refUrl) {
1268
+ if (typeof content == 'string') {
1269
+ content = {
1270
+ txt: content
1271
+ };
1272
+ }
1273
+
1274
+ if (!content || !content.txt || content.txt.length < at + len) {
1275
+ return null;
1276
+ }
1277
+
1278
+ if (len <= 0 || ['url', 'pub'].indexOf(actionType) == -1) {
1279
+ return null;
1280
+ }
1281
+ // Ensure refUrl is a string.
1282
+ if (actionType == 'url' && !refUrl) {
1283
+ return null;
1284
+ }
1285
+ refUrl = '' + refUrl;
1286
+
1287
+ content.ent = content.ent || [];
1288
+ content.fmt = content.fmt || [];
1289
+
1290
+ content.fmt.push({
1291
+ at: at | 0,
1292
+ len: len,
1293
+ key: content.ent.length
1294
+ });
1295
+ content.ent.push({
1296
+ tp: 'BN',
1297
+ data: {
1298
+ act: actionType,
1299
+ val: actionValue,
1300
+ ref: refUrl,
1301
+ name: name
1302
+ }
1303
+ });
1304
+
1305
+ return content;
1306
+ }
1307
+
1308
+ /**
1309
+ * Append clickable button to Drafty document.
1310
+ * @memberof Drafty
1311
+ * @static
1312
+ *
1313
+ * @param {Drafty|string} content - Drafty document to insert button to or a string to be used as button text.
1314
+ * @param {string} title - the text to be used as button title.
1315
+ * @param {string} name - the button. Client should return it to the server when the button is clicked.
1316
+ * @param {string} actionType - the type of the button, one of 'url' or 'pub'.
1317
+ * @param {string} actionValue - the value to return on click:
1318
+ * @param {string} refUrl - the URL to go to when the 'url' button is clicked.
1319
+ *
1320
+ * @return {Drafty} updated document.
1321
+ */
1322
+ Drafty.appendButton = function(content, title, name, actionType, actionValue, refUrl) {
1323
+ content = content || {
1324
+ txt: ''
1325
+ };
1326
+ const at = content.txt.length;
1327
+ content.txt += title;
1328
+ return Drafty.insertButton(content, at, title.length, name, actionType, actionValue, refUrl);
1329
+ }
1330
+
1331
+ /**
1332
+ * Attach a generic JS object. The object is attached as a json string.
1333
+ * Intended for representing a form response.
1334
+ *
1335
+ * @memberof Drafty
1336
+ * @static
1337
+ *
1338
+ * @param {Drafty} content - Drafty document to attach file to.
1339
+ * @param {Object} data - data to convert to json string and attach.
1340
+ * @returns {Drafty} the same document as <code>content</code>.
1341
+ */
1342
+ Drafty.attachJSON = function(content, data) {
1343
+ content = content || {
1344
+ txt: ''
1345
+ };
1346
+ content.ent = content.ent || [];
1347
+ content.fmt = content.fmt || [];
1348
+
1349
+ content.fmt.push({
1350
+ at: -1,
1351
+ len: 0,
1352
+ key: content.ent.length
1353
+ });
1354
+
1355
+ content.ent.push({
1356
+ tp: 'EX',
1357
+ data: {
1358
+ mime: DRAFTY_FR_MIME_TYPE,
1359
+ val: data
1360
+ }
1361
+ });
1362
+
1363
+ return content;
1364
+ }
1365
+ /**
1366
+ * Append line break to a Drafty document.
1367
+ * @memberof Drafty
1368
+ * @static
1369
+ *
1370
+ * @param {Drafty} content - Drafty document to append linebreak to.
1371
+ * @returns {Drafty} the same document as <code>content</code>.
1372
+ */
1373
+ Drafty.appendLineBreak = function(content) {
1374
+ content = content || {
1375
+ txt: ''
1376
+ };
1377
+ content.fmt = content.fmt || [];
1378
+ content.fmt.push({
1379
+ at: stringToGraphemes(content.txt).length,
1380
+ len: 1,
1381
+ tp: 'BR'
1382
+ });
1383
+ content.txt += ' ';
1384
+
1385
+ return content;
1386
+ }
1387
+ /**
1388
+ * Given Drafty document, convert it to HTML.
1389
+ * No attempt is made to strip pre-existing html markup.
1390
+ * This is potentially unsafe because <code>content.txt</code> may contain malicious HTML
1391
+ * markup. DO NOT use in production code.
1392
+ *
1393
+ * @memberof Tinode.Drafty
1394
+ * @static
1395
+ *
1396
+ * @param {Drafty} doc - document to convert.
1397
+ *
1398
+ * @returns {string} HTML-representation of content.
1399
+ */
1400
+ Drafty.UNSAFE_toHTML = function(doc) {
1401
+ const tree = draftyToTree(doc);
1402
+ const htmlFormatter = function(type, data, values) {
1403
+ const tag = DECORATORS[type];
1404
+ let result = values ? values.join('') : '';
1405
+ if (tag) {
1406
+ result = tag.open(data) + result + tag.close(data);
1407
+ }
1408
+ return result;
1409
+ };
1410
+ return treeBottomUp(tree, htmlFormatter, 0);
1411
+ }
1412
+
1413
+ /**
1414
+ * Callback for applying custom formatting to a Drafty document.
1415
+ * Called once for each style span.
1416
+ * @memberof Drafty
1417
+ * @static
1418
+ *
1419
+ * @callback Formatter
1420
+ * @param {string} style - style code such as "ST" or "IM".
1421
+ * @param {Object} data - entity's data.
1422
+ * @param {Object} values - possibly styled subspans contained in this style span.
1423
+ * @param {number} index - index of the element guaranteed to be unique.
1424
+ */
1425
+
1426
+ /**
1427
+ * Convert Drafty document to a representation suitable for display.
1428
+ * The <code>context</code> may expose a function <code>getFormatter(style)</code>. If it's available
1429
+ * it will call it to obtain a <code>formatter</code> for a subtree of styles under the <code>style</code>.
1430
+ * @memberof Drafty
1431
+ * @static
1432
+ *
1433
+ * @param {Drafty|Object} content - Drafty document to transform.
1434
+ * @param {Formatter} formatter - callback which formats individual elements.
1435
+ * @param {Object} context - context provided to formatter as <code>this</code>.
1436
+ *
1437
+ * @return {Object} transformed object
1438
+ */
1439
+ Drafty.format = function(original, formatter, context) {
1440
+ return treeBottomUp(draftyToTree(original), formatter, 0, [], context);
1441
+ }
1442
+
1443
+ /**
1444
+ * Shorten Drafty document making the drafty text no longer than the limit.
1445
+ * @memberof Drafty
1446
+ * @static
1447
+ *
1448
+ * @param {Drafty|string} original - Drafty object to shorten.
1449
+ * @param {number} limit - length in characrets to shorten to.
1450
+ * @param {boolean} light - remove heavy data from entities.
1451
+ * @returns new shortened Drafty object leaving the original intact.
1452
+ */
1453
+ Drafty.shorten = function(original, limit, light) {
1454
+ let tree = draftyToTree(original);
1455
+ tree = shortenTree(tree, limit, '…');
1456
+ if (tree && light) {
1457
+ tree = lightEntity(tree);
1458
+ }
1459
+ return treeToDrafty({}, tree, []);
1460
+ }
1461
+
1462
+ /**
1463
+ * Transform Drafty doc for forwarding: strip leading @mention and any leading line breaks or whitespace.
1464
+ * @memberof Drafty
1465
+ * @static
1466
+ *
1467
+ * @param {Drafty|string} original - Drafty object to shorten.
1468
+ * @returns converted Drafty object leaving the original intact.
1469
+ */
1470
+ Drafty.forwardedContent = function(original) {
1471
+ let tree = draftyToTree(original);
1472
+ const rmMention = function(node) {
1473
+ if (node.type == 'MN') {
1474
+ if (!node.parent || !node.parent.type) {
1475
+ return null;
1476
+ }
1477
+ }
1478
+ return node;
1479
+ }
1480
+ // Strip leading mention.
1481
+ tree = treeTopDown(tree, rmMention);
1482
+ // Remove leading whitespace.
1483
+ tree = lTrim(tree);
1484
+ // Convert back to Drafty.
1485
+ return treeToDrafty({}, tree, []);
1486
+ }
1487
+
1488
+ /**
1489
+ * Prepare Drafty doc for wrapping into QQ as a reply:
1490
+ * - Replace forwarding mention with symbol '➦' and remove data (UID).
1491
+ * - Remove quoted text completely.
1492
+ * - Replace line breaks with spaces.
1493
+ * - Strip entities of heavy content.
1494
+ * - Move attachments to the end of the document.
1495
+ * @memberof Drafty
1496
+ * @static
1497
+ *
1498
+ * @param {Drafty|string} original - Drafty object to shorten.
1499
+ * @param {number} limit - length in characters to shorten to.
1500
+ * @returns converted Drafty object leaving the original intact.
1501
+ */
1502
+ Drafty.replyContent = function(original, limit) {
1503
+ const convMNnQQnBR = function(node) {
1504
+ if (node.type == 'QQ') {
1505
+ return null;
1506
+ } else if (node.type == 'MN') {
1507
+ if ((!node.parent || !node.parent.type) && (node.text || '').startsWith('➦')) {
1508
+ node.text = '➦';
1509
+ delete node.children;
1510
+ delete node.data;
1511
+ }
1512
+ } else if (node.type == 'BR') {
1513
+ node.text = ' ';
1514
+ delete node.type;
1515
+ delete node.children;
1516
+ }
1517
+ return node;
1518
+ }
1519
+
1520
+ let tree = draftyToTree(original);
1521
+ if (!tree) {
1522
+ return original;
1523
+ }
1524
+
1525
+ // Strip leading mention.
1526
+ tree = treeTopDown(tree, convMNnQQnBR);
1527
+ // Move attachments to the end of the doc.
1528
+ tree = attachmentsToEnd(tree, MAX_PREVIEW_ATTACHMENTS);
1529
+ // Shorten the doc.
1530
+ tree = shortenTree(tree, limit, '…');
1531
+ // Strip heavy elements except IM.data['val'] and VD.data['preview'] (have to keep them to generate previews later).
1532
+ const filter = node => {
1533
+ switch (node.type) {
1534
+ case 'IM':
1535
+ return ['val'];
1536
+ case 'VD':
1537
+ return ['preview'];
1538
+ }
1539
+ return null;
1540
+ };
1541
+ tree = lightEntity(tree, filter);
1542
+ // Convert back to Drafty.
1543
+ return treeToDrafty({}, tree, []);
1544
+ }
1545
+
1546
+
1547
+ /**
1548
+ * Generate drafty preview:
1549
+ * - Shorten the document.
1550
+ * - Strip all heavy entity data leaving just inline styles and entity references.
1551
+ * - Replace line breaks with spaces.
1552
+ * - Replace content of QQ with a space.
1553
+ * - Replace forwarding mention with symbol '➦'.
1554
+ * move all attachments to the end of the document and make them visible.
1555
+ * The <code>context</code> may expose a function <code>getFormatter(style)</code>. If it's available
1556
+ * it will call it to obtain a <code>formatter</code> for a subtree of styles under the <code>style</code>.
1557
+ * @memberof Drafty
1558
+ * @static
1559
+ *
1560
+ * @param {Drafty|string} original - Drafty object to shorten.
1561
+ * @param {number} limit - length in characters to shorten to.
1562
+ * @param {boolean} forwarding - this a forwarding message preview.
1563
+ * @returns new shortened Drafty object leaving the original intact.
1564
+ */
1565
+ Drafty.preview = function(original, limit, forwarding) {
1566
+ let tree = draftyToTree(original);
1567
+
1568
+ // Move attachments to the end.
1569
+ tree = attachmentsToEnd(tree, MAX_PREVIEW_ATTACHMENTS);
1570
+
1571
+ // Convert leading mention to '➦' and replace QQ and BR with a space ' '.
1572
+ const convMNnQQnBR = function(node) {
1573
+ if (node.type == 'MN') {
1574
+ if ((!node.parent || !node.parent.type) && (node.text || '').startsWith('➦')) {
1575
+ node.text = '➦';
1576
+ delete node.children;
1577
+ }
1578
+ } else if (node.type == 'QQ') {
1579
+ node.text = ' ';
1580
+ delete node.children;
1581
+ } else if (node.type == 'BR') {
1582
+ node.text = ' ';
1583
+ delete node.children;
1584
+ delete node.type;
1585
+ }
1586
+ return node;
1587
+ }
1588
+ tree = treeTopDown(tree, convMNnQQnBR);
1589
+
1590
+ tree = shortenTree(tree, limit, '…');
1591
+ if (forwarding) {
1592
+ // Keep some IM and VD data for preview.
1593
+ const filter = {
1594
+ IM: ['val'],
1595
+ VD: ['preview']
1596
+ };
1597
+ tree = lightEntity(tree, node => {
1598
+ return filter[node.type];
1599
+ });
1600
+ } else {
1601
+ tree = lightEntity(tree);
1602
+ }
1603
+
1604
+ // Convert back to Drafty.
1605
+ return treeToDrafty({}, tree, []);
1606
+ }
1607
+
1608
+ /**
1609
+ * Given Drafty document, convert it to plain text.
1610
+ * @memberof Drafty
1611
+ * @static
1612
+ *
1613
+ * @param {Drafty} content - document to convert to plain text.
1614
+ * @returns {string} plain-text representation of the drafty document.
1615
+ */
1616
+ Drafty.toPlainText = function(content) {
1617
+ return typeof content == 'string' ? content : content.txt;
1618
+ }
1619
+
1620
+ /**
1621
+ * Check if the document has no markup and no entities.
1622
+ * @memberof Drafty
1623
+ * @static
1624
+ *
1625
+ * @param {Drafty} content - content to check for presence of markup.
1626
+ * @returns <code>true</code> is content is plain text, <code>false</code> otherwise.
1627
+ */
1628
+ Drafty.isPlainText = function(content) {
1629
+ return typeof content == 'string' || !(content.fmt || content.ent);
1630
+ }
1631
+
1632
+ /**
1633
+ * Convert document to plain text with markdown. All elements which cannot
1634
+ * be represented in markdown are stripped.
1635
+ * @memberof Drafty
1636
+ * @static
1637
+ *
1638
+ * @param {Drafty} content - document to convert to plain text with markdown.
1639
+ */
1640
+ Drafty.toMarkdown = function(content) {
1641
+ let tree = draftyToTree(content);
1642
+ const mdFormatter = function(type, _, values) {
1643
+ const def = FORMAT_TAGS[type];
1644
+ let result = (values ? values.join('') : '');
1645
+ if (def) {
1646
+ if (def.isVoid) {
1647
+ result = def.md_tag || '';
1648
+ } else if (def.md_tag) {
1649
+ result = def.md_tag + result + def.md_tag;
1650
+ }
1651
+ }
1652
+ return result;
1653
+ };
1654
+ return treeBottomUp(tree, mdFormatter, 0);
1655
+ }
1656
+
1657
+ /**
1658
+ * Checks if the object represets is a valid Drafty document.
1659
+ * @memberof Drafty
1660
+ * @static
1661
+ *
1662
+ * @param {Drafty} content - content to check for validity.
1663
+ * @returns <code>true</code> is content is valid, <code>false</code> otherwise.
1664
+ */
1665
+ Drafty.isValid = function(content) {
1666
+ if (!content) {
1667
+ return false;
1668
+ }
1669
+
1670
+ const {
1671
+ txt,
1672
+ fmt,
1673
+ ent
1674
+ } = content;
1675
+
1676
+ if (!txt && txt !== '' && !fmt && !ent) {
1677
+ return false;
1678
+ }
1679
+
1680
+ const txt_type = typeof txt;
1681
+ if (txt_type != 'string' && txt_type != 'undefined' && txt !== null) {
1682
+ return false;
1683
+ }
1684
+
1685
+ if (typeof fmt != 'undefined' && !Array.isArray(fmt) && fmt !== null) {
1686
+ return false;
1687
+ }
1688
+
1689
+ if (typeof ent != 'undefined' && !Array.isArray(ent) && ent !== null) {
1690
+ return false;
1691
+ }
1692
+ return true;
1693
+ }
1694
+
1695
+ /**
1696
+ * Check if the drafty document has attachments: style EX and outside of normal rendering flow,
1697
+ * i.e. <code>at = -1</code>.
1698
+ * @memberof Drafty
1699
+ * @static
1700
+ *
1701
+ * @param {Drafty} content - document to check for attachments.
1702
+ * @returns <code>true</code> if there are attachments.
1703
+ */
1704
+ Drafty.hasAttachments = function(content) {
1705
+ if (!Array.isArray(content.fmt)) {
1706
+ return false;
1707
+ }
1708
+ for (let i in content.fmt) {
1709
+ const fmt = content.fmt[i];
1710
+ if (fmt && fmt.at < 0) {
1711
+ const ent = content.ent[fmt.key | 0];
1712
+ return ent && ent.tp == 'EX' && ent.data;
1713
+ }
1714
+ }
1715
+ return false;
1716
+ }
1717
+
1718
+ /**
1719
+ * Callback for enumerating entities in a Drafty document.
1720
+ * Called once for each entity.
1721
+ * @memberof Drafty
1722
+ * @static
1723
+ *
1724
+ * @callback EntityCallback
1725
+ * @param {Object} data entity data.
1726
+ * @param {string} entity type.
1727
+ * @param {number} index entity's index in `content.ent`.
1728
+ *
1729
+ * @return 'true-ish' to stop processing, 'false-ish' otherwise.
1730
+ */
1731
+
1732
+ /**
1733
+ * Enumerate attachments: style EX and outside of normal rendering flow, i.e. <code>at = -1</code>.
1734
+ * @memberof Drafty
1735
+ * @static
1736
+ *
1737
+ * @param {Drafty} content - document to process for attachments.
1738
+ * @param {EntityCallback} callback - callback to call for each attachment.
1739
+ * @param {Object} context - value of "this" for callback.
1740
+ */
1741
+ Drafty.attachments = function(content, callback, context) {
1742
+ if (!Array.isArray(content.fmt)) {
1743
+ return;
1744
+ }
1745
+ let count = 0;
1746
+ for (let i in content.fmt) {
1747
+ let fmt = content.fmt[i];
1748
+ if (fmt && fmt.at < 0) {
1749
+ const ent = content.ent[fmt.key | 0];
1750
+ if (ent && ent.tp == 'EX' && ent.data) {
1751
+ if (callback.call(context, ent.data, count++, 'EX')) {
1752
+ break;
1753
+ }
1754
+ }
1755
+ }
1756
+ };
1757
+ }
1758
+
1759
+ /**
1760
+ * Check if the drafty document has entities.
1761
+ * @memberof Drafty
1762
+ * @static
1763
+ *
1764
+ * @param {Drafty} content - document to check for entities.
1765
+ * @returns <code>true</code> if there are entities.
1766
+ */
1767
+ Drafty.hasEntities = function(content) {
1768
+ return content.ent && content.ent.length > 0;
1769
+ }
1770
+
1771
+ /**
1772
+ * Enumerate entities. Enumeration stops if callback returns 'true'.
1773
+ * @memberof Drafty
1774
+ * @static
1775
+ *
1776
+ * @param {Drafty} content - document with entities to enumerate.
1777
+ * @param {EntityCallback} callback - callback to call for each entity.
1778
+ * @param {Object} context - value of "this" for callback.
1779
+ *
1780
+ */
1781
+ Drafty.entities = function(content, callback, context) {
1782
+ if (content.ent && content.ent.length > 0) {
1783
+ for (let i in content.ent) {
1784
+ if (content.ent[i]) {
1785
+ if (callback.call(context, content.ent[i].data, i, content.ent[i].tp)) {
1786
+ break;
1787
+ }
1788
+ }
1789
+ }
1790
+ }
1791
+ }
1792
+
1793
+ /**
1794
+ * Callback for enumerating styles (inline formats) in a Drafty document.
1795
+ * Called once for each style.
1796
+ * @memberof Drafty
1797
+ * @static
1798
+ *
1799
+ * @callback StyleCallback
1800
+ * @param {string} tp - format type.
1801
+ * @param {number} at - starting position of the format in text.
1802
+ * @param {number} len - extent of the format in characters.
1803
+ * @param {number} key - index of the entity if format is a reference.
1804
+ * @param {number} index - style's index in `content.fmt`.
1805
+ *
1806
+ * @return 'true-ish' to stop processing, 'false-ish' otherwise.
1807
+ */
1808
+
1809
+ /**
1810
+ * Enumerate styles (inline formats). Enumeration stops if callback returns 'true'.
1811
+ * @memberof Drafty
1812
+ * @static
1813
+ *
1814
+ * @param {Drafty} content - document with styles (formats) to enumerate.
1815
+ * @param {StyleCallback} callback - callback to call for each format.
1816
+ * @param {Object} context - value of "this" for callback.
1817
+ */
1818
+ Drafty.styles = function(content, callback, context) {
1819
+ if (content.fmt && content.fmt.length > 0) {
1820
+ for (let i in content.fmt) {
1821
+ const fmt = content.fmt[i];
1822
+ if (fmt) {
1823
+ if (callback.call(context, fmt.tp, fmt.at, fmt.len, fmt.key, i)) {
1824
+ break;
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1829
+ }
1830
+
1831
+ /**
1832
+ * Remove unrecognized fields from entity data
1833
+ * @memberof Drafty
1834
+ * @static
1835
+ *
1836
+ * @param {Drafty} content - document with entities to enumerate.
1837
+ * @returns content.
1838
+ */
1839
+ Drafty.sanitizeEntities = function(content) {
1840
+ if (content && content.ent && content.ent.length > 0) {
1841
+ for (let i in content.ent) {
1842
+ const ent = content.ent[i];
1843
+ if (ent && ent.data) {
1844
+ const data = copyEntData(ent.data);
1845
+ if (data) {
1846
+ content.ent[i].data = data;
1847
+ } else {
1848
+ delete content.ent[i].data;
1849
+ }
1850
+ }
1851
+ }
1852
+ }
1853
+ return content;
1854
+ }
1855
+
1856
+ /**
1857
+ * Given the entity, get URL which can be used for downloading
1858
+ * entity data.
1859
+ * @memberof Drafty
1860
+ * @static
1861
+ *
1862
+ * @param {Object} entData - entity.data to get the URl from.
1863
+ * @returns {string} URL to download entity data or <code>null</code>.
1864
+ */
1865
+ Drafty.getDownloadUrl = function(entData) {
1866
+ let url = null;
1867
+ if (!Drafty.isFormResponseType(entData.mime) && entData.val) {
1868
+ url = base64toObjectUrl(entData.val, entData.mime, Drafty.logger);
1869
+ } else if (typeof entData.ref == 'string') {
1870
+ url = entData.ref;
1871
+ }
1872
+ return url;
1873
+ }
1874
+
1875
+ /**
1876
+ * Check if the entity data is not ready for sending, such as being uploaded to the server.
1877
+ * @memberof Drafty
1878
+ * @static
1879
+ *
1880
+ * @param {Object} entity.data to get the URl from.
1881
+ * @returns {boolean} true if upload is in progress, false otherwise.
1882
+ */
1883
+ Drafty.isProcessing = function(entData) {
1884
+ return !!entData._processing;
1885
+ }
1886
+
1887
+ /**
1888
+ * Given the entity, get URL which can be used for previewing
1889
+ * the entity.
1890
+ * @memberof Drafty
1891
+ * @static
1892
+ *
1893
+ * @param {Object} entity.data to get the URl from.
1894
+ *
1895
+ * @returns {string} url for previewing or null if no such url is available.
1896
+ */
1897
+ Drafty.getPreviewUrl = function(entData) {
1898
+ return entData.val ? base64toObjectUrl(entData.val, entData.mime, Drafty.logger) : null;
1899
+ }
1900
+
1901
+ /**
1902
+ * Get approximate size of the entity.
1903
+ * @memberof Drafty
1904
+ * @static
1905
+ *
1906
+ * @param {Object} entData - entity.data to get the size for.
1907
+ * @returns {number} size of entity data in bytes.
1908
+ */
1909
+ Drafty.getEntitySize = function(entData) {
1910
+ // Either size hint or length of value. The value is base64 encoded,
1911
+ // the actual object size is smaller than the encoded length.
1912
+ return entData.size ? entData.size : entData.val ? (entData.val.length * 0.75) | 0 : 0;
1913
+ }
1914
+
1915
+ /**
1916
+ * Get entity mime type.
1917
+ * @memberof Drafty
1918
+ * @static
1919
+ *
1920
+ * @param {Object} entData - entity.data to get the type for.
1921
+ * @returns {string} mime type of entity.
1922
+ */
1923
+ Drafty.getEntityMimeType = function(entData) {
1924
+ return entData.mime || 'text/plain';
1925
+ }
1926
+
1927
+ /**
1928
+ * Get HTML tag for a given two-letter style name.
1929
+ * @memberof Drafty
1930
+ * @static
1931
+ *
1932
+ * @param {string} style - two-letter style, like ST or LN.
1933
+ *
1934
+ * @returns {string} HTML tag name if style is found, {code: undefined} if style is falsish or not found.
1935
+ */
1936
+ Drafty.tagName = function(style) {
1937
+ return FORMAT_TAGS[style] && FORMAT_TAGS[style].html_tag;
1938
+ }
1939
+
1940
+ /**
1941
+ * For a given data bundle generate an object with HTML attributes,
1942
+ * for instance, given {url: "http://www.example.com/"} return
1943
+ * {href: "http://www.example.com/"}
1944
+ * @memberof Drafty
1945
+ * @static
1946
+ *
1947
+ * @param {string} style - two-letter style to generate attributes for.
1948
+ * @param {Object} data - data bundle to convert to attributes
1949
+ *
1950
+ * @returns {Object} object with HTML attributes.
1951
+ */
1952
+ Drafty.attrValue = function(style, data) {
1953
+ if (data && DECORATORS[style] && DECORATORS[style].props) {
1954
+ return DECORATORS[style].props(data);
1955
+ }
1956
+
1957
+ return undefined;
1958
+ }
1959
+
1960
+ /**
1961
+ * Drafty MIME type.
1962
+ * @memberof Drafty
1963
+ * @static
1964
+ *
1965
+ * @returns {string} content-Type "text/x-drafty".
1966
+ */
1967
+ Drafty.getContentType = function() {
1968
+ return DRAFTY_MIME_TYPE;
1969
+ }
1970
+
1971
+ /**
1972
+ * Check if the given mime-type is a MIME type of drafty form response.
1973
+ * @memberof Drafty
1974
+ * @static
1975
+ *
1976
+ * @returns {boolean} <code>true</code> if given mime type is drafty form response, <code>false</code> otherwise.
1977
+ */
1978
+ Drafty.isFormResponseType = function(mimeType) {
1979
+ return mimeType === DRAFTY_FR_MIME_TYPE ||
1980
+ mimeType === DRAFTY_FR_MIME_TYPE_LEGACY;
1981
+ }
1982
+
1983
+ // =================
1984
+ // Utility methods.
1985
+ // =================
1986
+
1987
+ // Take a string and defined earlier style spans, re-compose them into a tree where each leaf is
1988
+ // a same-style (including unstyled) string. I.e. 'hello *bold _italic_* and ~more~ world' ->
1989
+ // ('hello ', (b: 'bold ', (i: 'italic')), ' and ', (s: 'more'), ' world');
1990
+ //
1991
+ // This is needed in order to clear markup, i.e. 'hello *world*' -> 'hello world' and convert
1992
+ // ranges from markup-ed offsets to plain text offsets.
1993
+ function chunkify(line, start, end, spans) {
1994
+ const chunks = [];
1995
+
1996
+ if (spans.length == 0) {
1997
+ return [];
1998
+ }
1999
+
2000
+ for (let i in spans) {
2001
+ // Get the next chunk from the queue
2002
+ const span = spans[i];
2003
+
2004
+ // Grab the initial unstyled chunk
2005
+ if (span.at > start) {
2006
+ chunks.push({
2007
+ txt: line.slice(start, span.at)
2008
+ });
2009
+ }
2010
+
2011
+ // Grab the styled chunk. It may include subchunks.
2012
+ const chunk = {
2013
+ tp: span.tp
2014
+ };
2015
+ const chld = chunkify(line, span.at + 1, span.end, span.children);
2016
+ if (chld.length > 0) {
2017
+ chunk.children = chld;
2018
+ } else {
2019
+ chunk.txt = span.txt;
2020
+ }
2021
+ chunks.push(chunk);
2022
+ start = span.end + 1; // '+1' is to skip the formatting character
2023
+ }
2024
+
2025
+ // Grab the remaining unstyled chunk, after the last span
2026
+ if (start < end) {
2027
+ chunks.push({
2028
+ txt: line.slice(start, end)
2029
+ });
2030
+ }
2031
+
2032
+ return chunks;
2033
+ }
2034
+
2035
+ // Detect starts and ends of formatting spans. Unformatted spans are
2036
+ // ignored at this stage.
2037
+ function spannify(original, re_start, re_end, type) {
2038
+ const result = [];
2039
+ let index = 0;
2040
+ let line = original.slice(0); // make a copy;
2041
+
2042
+ while (line.length > 0) {
2043
+ // match[0]; // match, like '*abc*'
2044
+ // match[1]; // match captured in parenthesis, like 'abc'
2045
+ // match['index']; // offset where the match started.
2046
+
2047
+ // Find the opening token.
2048
+ const start = re_start.exec(line);
2049
+ if (start == null) {
2050
+ break;
2051
+ }
2052
+
2053
+ // Because javascript RegExp does not support lookbehind, the actual offset may not point
2054
+ // at the markup character. Find it in the matched string.
2055
+ let start_offset = start['index'] + start[0].lastIndexOf(start[1]);
2056
+ // Clip the processed part of the string.
2057
+ line = line.slice(start_offset + 1);
2058
+ // start_offset is an offset within the clipped string. Convert to original index.
2059
+ start_offset += index;
2060
+ // Index now point to the beginning of 'line' within the 'original' string.
2061
+ index = start_offset + 1;
2062
+
2063
+ // Find the matching closing token.
2064
+ const end = re_end ? re_end.exec(line) : null;
2065
+ if (end == null) {
2066
+ break;
2067
+ }
2068
+ let end_offset = end['index'] + end[0].indexOf(end[1]);
2069
+ // Clip the processed part of the string.
2070
+ line = line.slice(end_offset + 1);
2071
+ // Update offsets
2072
+ end_offset += index;
2073
+ // Index now points to the beginning of 'line' within the 'original' string.
2074
+ index = end_offset + 1;
2075
+
2076
+ result.push({
2077
+ txt: original.slice(start_offset + 1, end_offset),
2078
+ children: [],
2079
+ at: start_offset,
2080
+ end: end_offset,
2081
+ tp: type
2082
+ });
2083
+ }
2084
+
2085
+ return result;
2086
+ }
2087
+
2088
+ // Convert linear array or spans into a tree representation.
2089
+ // Keep standalone and nested spans, throw away partially overlapping spans.
2090
+ function toSpanTree(spans) {
2091
+ if (spans.length == 0) {
2092
+ return [];
2093
+ }
2094
+
2095
+ const tree = [spans[0]];
2096
+ let last = spans[0];
2097
+ for (let i = 1; i < spans.length; i++) {
2098
+ // Keep spans which start after the end of the previous span or those which
2099
+ // are complete within the previous span.
2100
+ if (spans[i].at > last.end) {
2101
+ // Span is completely outside of the previous span.
2102
+ tree.push(spans[i]);
2103
+ last = spans[i];
2104
+ } else if (spans[i].end <= last.end) {
2105
+ // Span is fully inside of the previous span. Push to subnode.
2106
+ last.children.push(spans[i]);
2107
+ }
2108
+ // Span could partially overlap, ignoring it as invalid.
2109
+ }
2110
+
2111
+ // Recursively rearrange the subnodes.
2112
+ for (let i in tree) {
2113
+ tree[i].children = toSpanTree(tree[i].children);
2114
+ }
2115
+
2116
+ return tree;
2117
+ }
2118
+
2119
+ // Convert drafty document to a tree.
2120
+ function draftyToTree(doc) {
2121
+ if (!doc) {
2122
+ return null;
2123
+ }
2124
+
2125
+ doc = (typeof doc == 'string') ? {
2126
+ txt: doc
2127
+ } : doc;
2128
+ let {
2129
+ txt,
2130
+ fmt,
2131
+ ent
2132
+ } = doc;
2133
+
2134
+ txt = txt || '';
2135
+ if (!Array.isArray(ent)) {
2136
+ ent = [];
2137
+ }
2138
+
2139
+ if (!Array.isArray(fmt) || fmt.length == 0) {
2140
+ if (ent.length == 0) {
2141
+ return {
2142
+ text: txt
2143
+ };
2144
+ }
2145
+
2146
+ // Handle special case when all values in fmt are 0 and fmt therefore is skipped.
2147
+ fmt = [{
2148
+ at: 0,
2149
+ len: 0,
2150
+ key: 0
2151
+ }];
2152
+ }
2153
+
2154
+ // Sanitize spans.
2155
+ const spans = [];
2156
+ const attachments = [];
2157
+ fmt.forEach((span) => {
2158
+ if (!span || typeof span != 'object') {
2159
+ return;
2160
+ }
2161
+
2162
+ if (!['undefined', 'number'].includes(typeof span.at)) {
2163
+ // Present, but non-numeric 'at'.
2164
+ return;
2165
+ }
2166
+ if (!['undefined', 'number'].includes(typeof span.len)) {
2167
+ // Present, but non-numeric 'len'.
2168
+ return;
2169
+ }
2170
+ let at = span.at | 0;
2171
+ let len = span.len | 0;
2172
+ if (len < 0) {
2173
+ // Invalid span length.
2174
+ return;
2175
+ }
2176
+
2177
+ let key = span.key || 0;
2178
+ if (ent.length > 0 && (typeof key != 'number' || key < 0 || key >= ent.length)) {
2179
+ // Invalid key value.
2180
+ return;
2181
+ }
2182
+
2183
+ if (at <= -1) {
2184
+ // Attachment. Store attachments separately.
2185
+ attachments.push({
2186
+ start: -1,
2187
+ end: 0,
2188
+ key: key
2189
+ });
2190
+ return;
2191
+ } else if (at + len > stringToGraphemes(txt).length) {
2192
+ // Span is out of bounds.
2193
+ return;
2194
+ }
2195
+
2196
+ if (!span.tp) {
2197
+ if (ent.length > 0 && (typeof ent[key] == 'object')) {
2198
+ spans.push({
2199
+ start: at,
2200
+ end: at + len,
2201
+ key: key
2202
+ });
2203
+ }
2204
+ } else {
2205
+ spans.push({
2206
+ type: span.tp,
2207
+ start: at,
2208
+ end: at + len
2209
+ });
2210
+ }
2211
+ });
2212
+
2213
+ // Sort spans first by start index (asc) then by length (desc), then by weight.
2214
+ spans.sort((a, b) => {
2215
+ let diff = a.start - b.start;
2216
+ if (diff != 0) {
2217
+ return diff;
2218
+ }
2219
+ diff = b.end - a.end;
2220
+ if (diff != 0) {
2221
+ return diff;
2222
+ }
2223
+ return FMT_WEIGHT.indexOf(b.type) - FMT_WEIGHT.indexOf(a.type);
2224
+ });
2225
+
2226
+ // Move attachments to the end of the list.
2227
+ if (attachments.length > 0) {
2228
+ spans.push(...attachments);
2229
+ }
2230
+
2231
+ spans.forEach((span) => {
2232
+ if (ent.length > 0 && !span.type && ent[span.key] && typeof ent[span.key] == 'object') {
2233
+ span.type = ent[span.key].tp;
2234
+ span.data = ent[span.key].data;
2235
+ }
2236
+
2237
+ // Is type still undefined? Hide the invalid element!
2238
+ if (!span.type) {
2239
+ span.type = 'HD';
2240
+ }
2241
+ });
2242
+
2243
+ const graphemes = stringToGraphemes(txt);
2244
+ let tree = spansToTree({}, graphemes, 0, graphemes.length, spans);
2245
+
2246
+ // Flatten tree nodes.
2247
+ const flatten = function(node) {
2248
+ if (Array.isArray(node.children) && node.children.length == 1) {
2249
+ // Unwrap.
2250
+ const child = node.children[0];
2251
+ if (!node.type) {
2252
+ const parent = node.parent;
2253
+ node = child;
2254
+ node.parent = parent;
2255
+ } else if (!child.type && !child.children) {
2256
+ node.text = child.text;
2257
+ delete node.children;
2258
+ }
2259
+ }
2260
+ return node;
2261
+ }
2262
+ tree = treeTopDown(tree, flatten);
2263
+
2264
+ return tree;
2265
+ }
2266
+
2267
+ // Add tree node to a parent tree.
2268
+ function addNode(parent, n) {
2269
+ if (!n) {
2270
+ return parent;
2271
+ }
2272
+
2273
+ if (!parent.children) {
2274
+ parent.children = [];
2275
+ }
2276
+
2277
+ // If text is present, move it to a subnode.
2278
+ if (parent.text) {
2279
+ parent.children.push({
2280
+ text: parent.text,
2281
+ parent: parent
2282
+ });
2283
+ delete parent.text;
2284
+ }
2285
+
2286
+ n.parent = parent;
2287
+ parent.children.push(n);
2288
+
2289
+ return parent;
2290
+ }
2291
+
2292
+ // Returns a tree of nodes.
2293
+ function spansToTree(parent, graphemes, start, end, spans) {
2294
+ if (!spans || spans.length == 0) {
2295
+ if (start < end) {
2296
+ addNode(parent, {
2297
+ text: graphemes.slice(start, end)
2298
+ .map(segment => segment.segment)
2299
+ .join('')
2300
+ });
2301
+ }
2302
+ return parent;
2303
+ }
2304
+
2305
+ // Process subspans.
2306
+ for (let i = 0; i < spans.length; i++) {
2307
+ const span = spans[i];
2308
+ if (span.start < 0 && span.type == 'EX') {
2309
+ addNode(parent, {
2310
+ type: span.type,
2311
+ data: span.data,
2312
+ key: span.key,
2313
+ att: true
2314
+ });
2315
+ continue;
2316
+ }
2317
+
2318
+ // Add un-styled range before the styled span starts.
2319
+ if (start < span.start) {
2320
+ addNode(parent, {
2321
+ text: graphemes.slice(start, span.start)
2322
+ .map(segment => segment.segment)
2323
+ .join('')
2324
+ });
2325
+ start = span.start;
2326
+ }
2327
+
2328
+ // Get all spans which are within the current span.
2329
+ const subspans = [];
2330
+ while (i < spans.length - 1) {
2331
+ const inner = spans[i + 1];
2332
+ if (inner.start < 0) {
2333
+ // Attachments are in the end. Stop.
2334
+ break;
2335
+ } else if (inner.start < span.end) {
2336
+ if (inner.end <= span.end) {
2337
+ const tag = FORMAT_TAGS[inner.tp] || {};
2338
+ if (inner.start < inner.end || tag.isVoid) {
2339
+ // Valid subspan: completely within the current span and
2340
+ // either non-zero length or zero length is acceptable.
2341
+ subspans.push(inner);
2342
+ }
2343
+ }
2344
+ i++;
2345
+ // Overlapping subspans are ignored.
2346
+ } else {
2347
+ // Past the end of the current span. Stop.
2348
+ break;
2349
+ }
2350
+ }
2351
+
2352
+ addNode(parent, spansToTree({
2353
+ type: span.type,
2354
+ data: span.data,
2355
+ key: span.key
2356
+ }, graphemes, start, span.end, subspans));
2357
+ start = span.end;
2358
+ }
2359
+
2360
+ // Add the last unformatted range.
2361
+ if (start < end) {
2362
+ addNode(parent, {
2363
+ text: graphemes
2364
+ .slice(start, end)
2365
+ .map((segment) => segment.segment)
2366
+ .join('')
2367
+ });
2368
+ }
2369
+
2370
+ return parent;
2371
+ }
2372
+
2373
+ // Append a tree to a Drafty doc.
2374
+ function treeToDrafty(doc, tree, keymap) {
2375
+ if (!tree) {
2376
+ return doc;
2377
+ }
2378
+
2379
+ doc.txt = doc.txt || '';
2380
+
2381
+ // Checkpoint to measure length of the current tree node.
2382
+ const start = stringToGraphemes(doc.txt).length;
2383
+
2384
+ if (tree.text) {
2385
+ doc.txt += tree.text;
2386
+ } else if (Array.isArray(tree.children)) {
2387
+ tree.children.forEach((c) => {
2388
+ treeToDrafty(doc, c, keymap);
2389
+ });
2390
+ }
2391
+
2392
+ if (tree.type) {
2393
+ const len = stringToGraphemes(doc.txt).length - start;
2394
+ doc.fmt = doc.fmt || [];
2395
+ if (Object.keys(tree.data || {}).length > 0) {
2396
+ doc.ent = doc.ent || [];
2397
+ const newKey = (typeof keymap[tree.key] == 'undefined') ? doc.ent.length : keymap[tree.key];
2398
+ keymap[tree.key] = newKey;
2399
+ doc.ent[newKey] = {
2400
+ tp: tree.type,
2401
+ data: tree.data
2402
+ };
2403
+ if (tree.att) {
2404
+ // Attachment.
2405
+ doc.fmt.push({
2406
+ at: -1,
2407
+ len: 0,
2408
+ key: newKey
2409
+ });
2410
+ } else {
2411
+ doc.fmt.push({
2412
+ at: start,
2413
+ len: len,
2414
+ key: newKey
2415
+ });
2416
+ }
2417
+ } else {
2418
+ doc.fmt.push({
2419
+ tp: tree.type,
2420
+ at: start,
2421
+ len: len
2422
+ });
2423
+ }
2424
+ }
2425
+ return doc;
2426
+ }
2427
+
2428
+ // Traverse the tree top down transforming the nodes: apply transformer to every tree node.
2429
+ function treeTopDown(src, transformer, context) {
2430
+ if (!src) {
2431
+ return null;
2432
+ }
2433
+
2434
+ let dst = transformer.call(context, src);
2435
+ if (!dst || !dst.children) {
2436
+ return dst;
2437
+ }
2438
+
2439
+ const children = [];
2440
+ for (let i in dst.children) {
2441
+ let n = dst.children[i];
2442
+ if (n) {
2443
+ n = treeTopDown(n, transformer, context);
2444
+ if (n) {
2445
+ children.push(n);
2446
+ }
2447
+ }
2448
+ }
2449
+
2450
+ if (children.length == 0) {
2451
+ dst.children = null;
2452
+ } else {
2453
+ dst.children = children;
2454
+ }
2455
+
2456
+ return dst;
2457
+ }
2458
+
2459
+ // Traverse the tree bottom-up: apply formatter to every node.
2460
+ // The formatter must maintain its state through context.
2461
+ function treeBottomUp(src, formatter, index, stack, context) {
2462
+ if (!src) {
2463
+ return null;
2464
+ }
2465
+
2466
+ if (stack && src.type) {
2467
+ stack.push(src.type);
2468
+ }
2469
+
2470
+ let values = [];
2471
+ for (let i in src.children) {
2472
+ const n = treeBottomUp(src.children[i], formatter, i, stack, context);
2473
+ if (n) {
2474
+ values.push(n);
2475
+ }
2476
+ }
2477
+ if (values.length == 0) {
2478
+ if (src.text) {
2479
+ values = [src.text];
2480
+ } else {
2481
+ values = null;
2482
+ }
2483
+ }
2484
+
2485
+ if (stack && src.type) {
2486
+ stack.pop();
2487
+ }
2488
+
2489
+ return formatter.call(context, src.type, src.data, values, index, stack);
2490
+ }
2491
+
2492
+ // Clip tree to the provided limit.
2493
+ function shortenTree(tree, limit, tail) {
2494
+ if (!tree) {
2495
+ return null;
2496
+ }
2497
+
2498
+ if (tail) {
2499
+ limit -= tail.length;
2500
+ }
2501
+
2502
+ const shortener = function(node) {
2503
+ if (limit <= -1) {
2504
+ // Limit -1 means the doc was already clipped.
2505
+ return null;
2506
+ }
2507
+
2508
+ if (node.att) {
2509
+ // Attachments are unchanged.
2510
+ return node;
2511
+ }
2512
+ if (limit == 0) {
2513
+ node.text = tail;
2514
+ limit = -1;
2515
+ } else if (node.text) {
2516
+ const graphemes = stringToGraphemes(node.text);
2517
+ if (graphemes.length > limit) {
2518
+ node.text = graphemes
2519
+ .slice(0, limit)
2520
+ .map((segment) => segment.segment)
2521
+ .join('') + tail;
2522
+ limit = -1;
2523
+ } else {
2524
+ limit -= graphemes.length;
2525
+ }
2526
+ }
2527
+ return node;
2528
+ }
2529
+
2530
+ return treeTopDown(tree, shortener);
2531
+ }
2532
+
2533
+ // Strip heavy entities from a tree.
2534
+ function lightEntity(tree, allow) {
2535
+ const lightCopy = node => {
2536
+ const data = copyEntData(node.data, true, allow ? allow(node) : null);
2537
+ if (data) {
2538
+ node.data = data;
2539
+ } else {
2540
+ delete node.data;
2541
+ }
2542
+ return node;
2543
+ }
2544
+ return treeTopDown(tree, lightCopy);
2545
+ }
2546
+
2547
+ // Remove spaces and breaks on the left.
2548
+ function lTrim(tree) {
2549
+ if (tree.type == 'BR') {
2550
+ tree = null;
2551
+ } else if (tree.text) {
2552
+ if (!tree.type) {
2553
+ tree.text = tree.text.trimStart();
2554
+ if (!tree.text) {
2555
+ tree = null;
2556
+ }
2557
+ }
2558
+ } else if (!tree.type && tree.children && tree.children.length > 0) {
2559
+ const c = lTrim(tree.children[0]);
2560
+ if (c) {
2561
+ tree.children[0] = c;
2562
+ } else {
2563
+ tree.children.shift();
2564
+ if (!tree.type && tree.children.length == 0) {
2565
+ tree = null;
2566
+ }
2567
+ }
2568
+ }
2569
+ return tree;
2570
+ }
2571
+
2572
+ // Move attachments to the end. Attachments must be at the top level, no need to traverse the tree.
2573
+ function attachmentsToEnd(tree, limit) {
2574
+ if (!tree) {
2575
+ return null;
2576
+ }
2577
+
2578
+ if (tree.att) {
2579
+ tree.text = ' ';
2580
+ delete tree.att;
2581
+ delete tree.children;
2582
+ } else if (tree.children) {
2583
+ const attachments = [];
2584
+ const children = [];
2585
+ for (let i in tree.children) {
2586
+ const c = tree.children[i];
2587
+ if (c.att) {
2588
+ if (attachments.length == limit) {
2589
+ // Too many attachments to preview;
2590
+ continue;
2591
+ }
2592
+ if (Drafty.isFormResponseType(c.data['mime'])) {
2593
+ // Form response attachments are not shown in preview.
2594
+ continue;
2595
+ }
2596
+
2597
+ delete c.att;
2598
+ delete c.children;
2599
+ c.text = ' ';
2600
+ attachments.push(c);
2601
+ } else {
2602
+ children.push(c);
2603
+ }
2604
+ }
2605
+ tree.children = children.concat(attachments);
2606
+ }
2607
+ return tree;
2608
+ }
2609
+
2610
+ // Get a list of entities from a text.
2611
+ function extractEntities(line) {
2612
+ let match;
2613
+ let extracted = [];
2614
+ ENTITY_TYPES.forEach((entity) => {
2615
+ while ((match = entity.re.exec(line)) !== null) {
2616
+ extracted.push({
2617
+ offset: match['index'],
2618
+ len: match[0].length,
2619
+ unique: match[0],
2620
+ data: entity.pack(match[0]),
2621
+ type: entity.name
2622
+ });
2623
+ }
2624
+ });
2625
+
2626
+ if (extracted.length == 0) {
2627
+ return extracted;
2628
+ }
2629
+
2630
+ // Remove entities detected inside other entities, like #hashtag in a URL.
2631
+ extracted.sort((a, b) => {
2632
+ return a.offset - b.offset;
2633
+ });
2634
+
2635
+ let idx = -1;
2636
+ extracted = extracted.filter((el) => {
2637
+ const result = (el.offset > idx);
2638
+ idx = el.offset + el.len;
2639
+ return result;
2640
+ });
2641
+
2642
+ return extracted;
2643
+ }
2644
+
2645
+ // Convert the chunks into format suitable for serialization.
2646
+ function draftify(chunks, startAt) {
2647
+ let plain = '';
2648
+ let ranges = [];
2649
+ for (let i in chunks) {
2650
+ const chunk = chunks[i];
2651
+ if (!chunk.txt) {
2652
+ const drafty = draftify(chunk.children, plain.length + startAt);
2653
+ chunk.txt = drafty.txt;
2654
+ ranges = ranges.concat(drafty.fmt);
2655
+ }
2656
+
2657
+ if (chunk.tp) {
2658
+ ranges.push({
2659
+ at: plain.length + startAt,
2660
+ len: chunk.txt.length,
2661
+ tp: chunk.tp
2662
+ });
2663
+ }
2664
+
2665
+ plain += chunk.txt;
2666
+ }
2667
+ return {
2668
+ txt: plain,
2669
+ fmt: ranges
2670
+ };
2671
+ }
2672
+
2673
+ // Create a copy of entity data with (light=false) or without (light=true) the large payload.
2674
+ // The array 'allow' contains a list of fields exempt from stripping.
2675
+ function copyEntData(data, light, allow) {
2676
+ if (data && Object.entries(data).length > 0) {
2677
+ allow = allow || [];
2678
+ const dc = {};
2679
+ ALLOWED_ENT_FIELDS.forEach(key => {
2680
+ if (data[key]) {
2681
+ if (light && !allow.includes(key) &&
2682
+ (typeof data[key] == 'string' || Array.isArray(data[key])) &&
2683
+ data[key].length > MAX_PREVIEW_DATA_SIZE) {
2684
+ return;
2685
+ }
2686
+ if (typeof data[key] == 'object') {
2687
+ return;
2688
+ }
2689
+ dc[key] = data[key];
2690
+ }
2691
+ });
2692
+
2693
+ if (Object.entries(dc).length != 0) {
2694
+ return dc;
2695
+ }
2696
+ }
2697
+ return null;
2698
+ }
2699
+
2700
+ // Returns true if object is empty, if undefined returns true
2701
+ function isEmptyObject(obj) {
2702
+ return Object.keys(obj ?? {}).length == 0;
2703
+ };
2704
+
2705
+
2706
+ // Returns an array (of length equal to the length of the original string) such that each index
2707
+ // denotes the position of char in string in a grapheme array (created from that string)
2708
+ // Eg: string: "Hi👋🏼Hi" -> [0,1,2,2,2,2,3,4]
2709
+ function graphemeIndices(graphemes) {
2710
+ const result = [];
2711
+ let graphemeIndex = 0;
2712
+ let charIndex = 0;
2713
+
2714
+ // Iterate over the grapheme clusters.
2715
+ for (const {
2716
+ segment
2717
+ }
2718
+ of graphemes) {
2719
+ // Map the character indices to the grapheme index.
2720
+ for (let i = 0; i < segment.length; i++) {
2721
+ result[charIndex + i] = graphemeIndex;
2722
+ }
2723
+
2724
+ // Increment the character index by the length of the grapheme cluster.
2725
+ charIndex += segment.length;
2726
+
2727
+ // Increment the grapheme index.
2728
+ graphemeIndex++;
2729
+ }
2730
+
2731
+ return result;
2732
+ }
2733
+
2734
+ // Convert fmt.at and fmt.len from character-expressed index and length to grapheme-expressed
2735
+ // index and length.
2736
+ function toGraphemeValues(fmt, segments, txt) {
2737
+ segments = segments ?? segmenter.segment(txt);
2738
+
2739
+ const indices = graphemeIndices(segments);
2740
+
2741
+ const correctAt = indices[fmt.at];
2742
+ const correctLen = fmt.at + fmt.len <= txt.length ?
2743
+ indices[fmt.at + fmt.len - 1] - correctAt : fmt.len;
2744
+
2745
+ return {
2746
+ at: correctAt,
2747
+ len: correctLen + 1
2748
+ };
2749
+ }
2750
+
2751
+ // Convert string to graphme cluster array.
2752
+ function stringToGraphemes(str) {
2753
+ return Array.from(segmenter.segment(str));
2754
+ }
2755
+
2756
+ if (typeof module != 'undefined') {
2757
+ module.exports = Drafty;
2758
+ }