diff --git a/src/lib/formatters.js b/src/lib/formatters.js new file mode 100644 index 00000000..9be1f4c7 --- /dev/null +++ b/src/lib/formatters.js @@ -0,0 +1,237 @@ +import React from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; + +import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js'; +import { fitImageSize } from './blob-helpers.js'; +import { idToColorClass } from './strformat.js'; +import { sanitizeImageUrl } from './utils.js'; + +import { Drafty } from 'tinode-sdk'; + +const messages = defineMessages({ + drafty_form: { + id: 'drafty_form', + defaultMessage: 'Form: ', + description: 'Comment for form in drafty preview' + }, + drafty_attachment: { + id: 'drafty_attachment', + defaultMessage: 'Attachment', + description: 'Comment for attachment in drafty preview' + }, + drafty_image: { + id: 'drafty_image', + defaultMessage: 'Picture', + description: 'Comment for embedded images in drafty preview' + }, +}); + +function handleImageData(el, data, attr) { + if (!data) { + attr.src = 'img/broken_image.png'; + attr.style = { + width: BROKEN_IMAGE_SIZE + 'px', + height: BROKEN_IMAGE_SIZE + 'px', + // Looks like a Chrome bug: broken image does not respect 'width' and 'height'. + minWidth: BROKEN_IMAGE_SIZE + 'px', + minHeight: BROKEN_IMAGE_SIZE + 'px' + }; + return el; + } + + attr.className = 'inline-image'; + const dim = fitImageSize(data.width, data.height, + this.viewportWidth > 0 ? + Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) : REM_SIZE * 34.5, + REM_SIZE * 24, false) || {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE}; + attr.style = { + width: dim.dstWidth + 'px', + height: dim.dstHeight + 'px', + // Looks like a Chrome bug: broken image does not respect 'width' and 'height'. + minWidth: dim.dstWidth + 'px', + minHeight: dim.dstHeight + 'px' + }; + if (!Drafty.isProcessing(data)) { + attr.src = this.authorizeURL(sanitizeImageUrl(attr.src)); + attr.alt = data.name; + if (attr.src) { + if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) { + // Allow previews for large enough images. + attr.onClick = this.onImagePreview; + attr.className += ' image-clickable'; + } + attr.loading = 'lazy'; + } else { + attr.src = 'img/broken_image.png'; + } + } else { + // Use custom element instead of . + el = UploadingImage; + } + + return el; +} + +// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller. +// 'this' must contain: +// viewportWidth: this.props.viewportWidth; +// authorizeURL: this.props.tinode.authorizeURL +// onImagePreview: this.handleImagePreview +// onFormButtonClick: this.handleFormButtonClick +// onQuoteClick: this.handleQuoteClick (optional) +export function fullFormatter(style, data, values, key) { + if (style == 'EX') { + // attachments are handled elsewhere. + return null; + } + + let el = Drafty.tagName(style); + if (el) { + const attr = Drafty.attrValue(style, data) || {}; + attr.key = key; + switch (style) { + case 'HL': + // Highlighted text. Assign class name. + attr.className = 'highlight'; + break; + case 'IM': + // Additional processing for images + el = handleImageData.call(this, el, data, attr); + // Image element cannot have content. + values = null; + break; + case 'BN': + // Button + attr.onClick = this.onFormButtonClick; + let inner = React.Children.map(values, (child) => { + return typeof child == 'string' ? child : undefined; + }); + if (!inner || inner.length == 0) { + inner = [attr.name] + } + // Get text which will be sent back when the button is clicked. + attr['data-title'] = inner.join(''); + break; + case 'MN': + // Mention + if (data && data.hasOwnProperty('colorId')) { + attr.className = 'mn-dark-color' + data.colorId; + } + break; + case 'FM': + // Form + attr.className = 'bot-form'; + break; + case 'RW': + // Form element formatting is dependent on element content. + break; + case 'QQ': + // Quote/citation. + attr.className = 'reply-quote' + attr.onClick = this.onQuoteClick; + break; + default: + if (el == '_UNKN') { + // Unknown element. + el = React.Fragment; + values = [extension, ' '].concat(values || []); + } + break; + } + return React.createElement(el, attr, values); + } else { + return values; + } +}; + +// Converts Drafty object into a one-line preview. 'this' is set by the caller. +// 'this' must contain: +// formatMessage: this.props.intl.formatMessage +export function previewFormatter(style, data, values, key) { + let el = Drafty.tagName(style); + const attr = { key: key }; + if (el) { + switch (style) { + case 'BR': + // Replace new line with a space. + el = React.Fragment; + values = [' ']; + break; + case 'HL': + // Make highlight less prominent in preview. + attr.className = 'highlight preview'; + break; + case 'LN': + case 'MN': + // Disable links in previews. + el = 'span'; + break; + case 'IM': + // Replace image with '[icon] Image'. + el = React.Fragment; + values = [photo, this.formatMessage(messages.drafty_image)]; + break; + case 'BN': + el = 'span'; + attr.className = 'flat-button faux'; + break; + case 'FM': + el = React.Fragment; + values = [dashboard, + this.formatMessage(messages.drafty_form)].concat(' ', values || []); + break; + case 'RW': + el = React.Fragment; + break; + case 'EX': + if (data && data.mime == 'application/json') { + // Ignore JSON attachments: they are form response payloads. + return null; + } + el = React.Fragment; + values = [attachment, this.formatMessage(messages.drafty_attachment)]; + break; + default: + if (el == '_UNKN') { + el = React.Fragment; + values = [extension, ' '].concat(values || []); + } + break; + } + return React.createElement(el, attr, values); + } else { + return values; + } +}; + +// Converts Drafty object into a quoted reply. 'this' is set by the caller. +// 'this' must contain: +// formatMessage: this.props.intl.formatMessage +// authorizeURL: this.props.tinode.authorizeURL +// onQuoteClick: this.handleQuoteClick (optional) +export function quoteFormatter(style, data, values, key) { + if (['BR', 'IM', 'MN', 'QQ'].includes(style)) { + let el = Drafty.tagName(style); + const attr = Drafty.attrValue(style, data) || {}; + attr.key = key; + switch(style) { + case 'IM': + el = handleImageData.call(this, el, data, attr); + values = null; + break; + case 'MN': + el = 'span'; + attr.className = 'mention' + if (data) { + attr.className += ' ' + idToColorClass(data.val, false, true); + } + break; + case 'QQ': + attr.className = 'reply-quote'; + attr.onClick = this.onQuoteClick; + break; + } + return React.createElement(el, attr, values); + } + return previewFormatter.call(this, style, data, values, key); +}