diff --git a/README.md b/README.md index 64f8e2ff..18e3c16c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Background pattern from http://subtlepatterns.com/, commercial and non-commercia ### Desktop screenshot

- Desktop web: full app + Desktop web: full app

### Mobile screenshots diff --git a/css/base.css b/css/base.css index 0355c4ca..a8081702 100644 --- a/css/base.css +++ b/css/base.css @@ -262,6 +262,10 @@ ul.menu > li.separator:hover { color: #757575; } +.small { + font-size: 90%; +} + .hr { display: block; width: 100%; @@ -415,7 +419,6 @@ table.permission-editor td.checkbox { .permission-editor .material-icons { font-size: 1.1rem; line-height: 1.1rem; - vertical-align: middle; } .material-icons.clickable { @@ -544,7 +547,6 @@ table.permission-editor td.checkbox { } .alert-box .icon { display: block; - vertical-align: middle; margin-right: 0.5rem; } .alert-box .icon .material-icons { @@ -691,7 +693,6 @@ table.permission-editor td.checkbox { height: 1.5rem; font-size: 1.5rem; line-height: 1.5rem; - vertical-align: middle; color: #0097A7; position: absolute; right: 0.05rem; @@ -943,6 +944,49 @@ table.permission-editor td.checkbox { vertical-align: middle; } +#image-preview { + position: absolute; + display: flex; + flex-direction: column; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; + background-color: rgba(255,255,255,0.90); +} + +#image-preview-caption-panel { + display: flex; + justify-content: space-between; + background-color: transparent; + line-height: 2.5rem; + height: 2.5rem; + padding: 1rem 0.75rem 0 0.75rem; +} + +#image-preview-caption-panel a { + color: #757575; + text-decoration: none; +} + +#image-preview-caption-panel a:hover { + color: black; +} + +#image-preview-container { + margin: auto; + overflow: hidden; +} + +#image-preview-footer { + display: flex; + justify-content: space-around; + height: 3.2rem; + line-height: 1.5rem; + background-color: transparent; +} + /* Talk bubbles */ .chat-box { list-style: none; @@ -1078,7 +1122,7 @@ table.permission-editor td.checkbox { .chat-box .timestamp { display: inline-block; - position:relative; + position: relative; font-size: 80%; color: #777; padding-left: 1rem; @@ -1107,13 +1151,20 @@ table.permission-editor td.checkbox { color:#777; } +/* Inline image styles */ +.inline-image { + max-width: 36rem; + max-height: 24rem; + overflow: hidden; + border-radius: 0.1rem; + cursor: pointer; + display: block; +} + /* Attachment styles */ .attachment { display: flex; } -.attachment .material-icons { - vertical-align: middle; -} .attachment:last-of-type { margin-bottom: -1rem; } @@ -1162,12 +1213,13 @@ table.permission-editor td.checkbox { .dialog-overlay { position: absolute; display: flex; + flex-direction: column; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; - background-color: rgba(0,0,0,0.25); + background-color: rgba(255,255,255,0.90); } .modal-dialog { display: flex; @@ -1449,6 +1501,7 @@ ul.tabbar li.active > a:after { .material-icons { font-size: 1.4rem; line-height: 1; + vertical-align: middle; } .material-icons.small { font-size: 1rem; diff --git a/drafty.js b/drafty.js index b3fb8c8b..db480e82 100644 --- a/drafty.js +++ b/drafty.js @@ -89,7 +89,7 @@ Sample JSON representation of the text above: LN: { name: 'a', isVoid: false }, MN: { name: 'a', isVoid: false }, HT: { name: 'a', isVoid: false }, - IM: { name: 'img', isVoid: true } + IM: { name: ['a','img'], isVoid: true } }; // Convert base64-encoded string into Blob. @@ -141,12 +141,18 @@ Sample JSON representation of the text above: return (data.name ? '' : ''); }, props: function(data) { - return { - src: base64toObjectUrl(data.val, data.mime), - width: data.width, - height: data.height, - download: data.name - }; + var url = base64toObjectUrl(data.val, data.mime); + return [ + { + download: data.name, + href: url + }, + { + src: url, + width: data.width, + height: data.height, + } + ]; }, } }; @@ -703,6 +709,10 @@ Sample JSON representation of the text above: // Value is base64 encoded. The actual object size is smaller than the encoded length. return entity.val ? (entity.val.length * 0.75) | 0 : 0; }, + + getAttachmentType: function(entity) { + return entity.mime || "text/plain"; + }, /** * Get HTML tag for a given two-letter style name * @param {string} style - two-letter style, like ST or LN diff --git a/index.html b/index.html index 03c4ff9e..9dc31291 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,10 @@ // greater value in production. Also increase max_message_size in server config. var MAX_ATTACHMENT_SIZE = 1 << 17; + // Maximum allowed linear dimension of an inline image in pixels. You may want + // to adjust it to 1600 or 2400 for production. + var MAX_IMAGE_SIZE = 768; + // Short representation of time in the past. function shortDateFormat(then) { var locale = window.navigator.userLanguage || window.navigator.language; @@ -79,6 +83,38 @@ 'data:image/' + photo.type + ';base64,' + photo.data : null; } + // Convert uploaded image into a base64-encoded string. + function imageFileToBase64(file, callback) { + var img = new Image(); + img.crossOrigin = 'Anonymous'; + img.onload = function() { + var width, height; + if (this.height > this.width) { + height = this.height; + while(height > MAX_IMAGE_SIZE) { + height = height >> 1; + } + width = (this.width * height / this.height) | 0; + } else { + width = this.width; + while(width > MAX_IMAGE_SIZE) { + width = width >> 1; + } + height = (this.height * width / this.width) | 0; + } + var canvas = document.createElement('CANVAS'); + canvas.width = width; + canvas.height = height; + var ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.drawImage(this, 0, 0, this.width, this.height, 0, 0, canvas.width, canvas.height); + var imageBits = canvas.toDataURL("image/jpeg"); + canvas = null; + callback(imageBits, width, height); + }; + img.src = URL.createObjectURL(file); + } + // Make shortcut icon appear with a green dot. function updateFavicon(on) { var oldIcon = document.getElementById("shortcut-icon"); @@ -2308,18 +2344,56 @@ }; var Message = React.createClass({ + handlePreviewImage: function(e) { + e.preventDefault(); + this.props.onImagePreview({ + url: e.target.src, + filename: e.target.title, + width: e.target.dataset.width, + height: e.target.dataset.height, + size: e.target.dataset.size, + type: e.target.dataset.mime + }); + }, render: function() { var elementKey = 0; var formatter = function(style, data, values) { elementKey += 1; - var el = Drafty.tagName(style); - if (el) { - var attr = Drafty.attrValue(style, data) || {}; - attr.key = elementKey; - return React.createElement(el, attr, values); + if (style == "IM") { + // Handle inline image + var width = data.width, height = data.height; + var style = null; + if (width && height) { + var scale = 1.0; + if (data.width > REM_SIZE * 36) { + scale = REM_SIZE * 36 / data.width; + } + if (data.height * scale > REM_SIZE * 24) { + scale = REM_SIZE * 24 / data.height; + } + style = { + width: Math.round(data.width * scale / REM_SIZE) + "rem", + height: Math.round(data.height * scale / REM_SIZE) + "rem" + } + } + var url = Drafty.getBlobUrl(data); + return ( + + ); } else { - return values; + var el = Drafty.tagName(style); + if (el) { + var attr = Drafty.attrValue(style, data) || {}; + attr.key = elementKey; + return React.createElement(el, attr, values); + } else { + return values; + } } }; @@ -2338,7 +2412,7 @@ filename={att.name} blobUrl={Drafty.getBlobUrl(att)} size={bytesToHumanSize(Drafty.getAttachmentSize(att))} key={i} />); }, this); - content = React.createElement('span', null, Drafty.format(content, formatter)); + content = React.createElement('span', null, Drafty.format(content, formatter, this)); } return ( @@ -2402,7 +2476,7 @@
insert_drive_file
-
{filename} ({this.props.size})
+
{filename} ({this.props.size})
file_download save
@@ -2422,7 +2496,8 @@ scrollPosition: 0, readOnly: false, writeOnly: false, - typingIndicator: false + typingIndicator: false, + imagePreview: null }; }, @@ -2483,6 +2558,7 @@ this.setState({ messages: msgs, topic: props.topic, + imagePreview: null, scrollPosition: 0 }); tryToResubscribe = true; @@ -2618,6 +2694,14 @@ } }, + handleImagePreview: function(content) { + this.setState({ imagePreview: content }); + }, + + handleCloseImagePreview: function() { + this.setState({ imagePreview: null }); + }, + handleContextClick: function(e) { e.preventDefault(); e.stopPropagation(); @@ -2678,7 +2762,8 @@ + sequence={sequence} received={msgReceived} + onImagePreview={this.handleImagePreview} key={msg.seq} /> ); } @@ -2700,7 +2785,7 @@
{this.props.displayMobile ? - arrow_back + arrow_back : null} @@ -2716,7 +2801,7 @@
- more_vert + more_vert
@@ -2740,6 +2825,9 @@ disabled={this.state.readOnly} sendMessage={this.props.sendMessage} onError={this.props.onError} /> + {this.state.imagePreview ? + : null}
); } else { @@ -2766,11 +2854,18 @@ handleAttachImage: function(e) { if (e.target.files && e.target.files.length > 0) { var file = e.target.files[0]; - if (file.size > MAX_ATTACHMENT_SIZE) { - this.props.onError("The image size " + bytesToHumanSize(file.size) + - " exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " limit.", "err"); - return; - } + var instance = this; + imageFileToBase64(file, function(bits, width, height) { + if (bits.length > MAX_ATTACHMENT_SIZE) { + this.props.onError("The image size " + bytesToHumanSize(bits.length) + + " exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " limit.", "err"); + return; + } + + instance.props.sendMessage(Drafty.insertImage(null, + 0, file.type, bits.split(',')[1], width, height, + file.name)); + }); } }, @@ -2785,7 +2880,7 @@ var reader = new FileReader(); var instance = this; - reader.addEventListener("load", function () { + reader.addEventListener("load", function() { instance.props.sendMessage(Drafty.attachFile(null, file.type, reader.result.split(',')[1], file.name)); }, false); reader.readAsDataURL(file); @@ -2872,45 +2967,63 @@ ); } }); - /* END Conversation panel */ - /* This is a div which covers the entire AppView and shows selected modal dialog box */ - var ModalDialogWrapper = React.createClass({ - render: function() { - var dialog = null; - switch (this.props.dialog) { - case "new-topic": - dialog = ; - break; - default: - break; - } - if (dialog) { - dialog =
{dialog}
; - } - return dialog; - } - }); + MessagesView.ImagePreview = React.createClass({ + getInitialState: function() { + return {}; + }, + + componentDidMount: function() { + this.setState({ + width: this.container.clientWidth, + height: this.container.clientHeight - REM_SIZE * 7 + }); + }, - ModalDialogWrapper.NewTopic = React.createClass({ render: function() { + if (!this.props.content) { + return null; + } + + var instance = this; + var width = null, height = null; + console.log(this.props.content.width, this.props.content.height, this.state.width, this.state.height); + if (this.props.content.width && this.props.content.height) { + width = Math.max(Math.min(this.state.width, this.props.content.width), 8); + height = Math.max(Math.min(this.state.height, this.props.content.height), 8); + var scale = Math.min(width / this.props.content.width, height / this.props.content.height, 1.0); + width = (this.props.content.width * scale) | 0; + height = (this.props.content.height * scale) | 0; + } + var filename = this.props.content.filename; + if (filename.length > 20) { + filename = filename.slice(0, 8) + "..." + filename.slice(-8); + } return ( -
-
- New topic +
+ -
Not implemented
-
- Yes - OK +
+ +
+
); } }); - /* End of modal dialog wrapper */ + /* END Conversation panel */ /* The top-level class to hold all fuinctionality together */ var AppView = React.createClass({ @@ -3385,11 +3498,6 @@ render: function() { return (
- -