inline images work now

This commit is contained in:
or-else
2017-09-25 21:00:35 -07:00
parent 3e8fd9d6e6
commit 0a96014484
4 changed files with 239 additions and 68 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ Background pattern from http://subtlepatterns.com/, commercial and non-commercia
### Desktop screenshot ### Desktop screenshot
<p align="center"> <p align="center">
<img src="web-desktop-1.png" alt="Desktop web: full app" width=866 /> <img src="web-desktop-2.png" alt="Desktop web: full app" width=866 />
</p> </p>
### Mobile screenshots ### Mobile screenshots
+61 -8
View File
@@ -262,6 +262,10 @@ ul.menu > li.separator:hover {
color: #757575; color: #757575;
} }
.small {
font-size: 90%;
}
.hr { .hr {
display: block; display: block;
width: 100%; width: 100%;
@@ -415,7 +419,6 @@ table.permission-editor td.checkbox {
.permission-editor .material-icons { .permission-editor .material-icons {
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.1rem; line-height: 1.1rem;
vertical-align: middle;
} }
.material-icons.clickable { .material-icons.clickable {
@@ -544,7 +547,6 @@ table.permission-editor td.checkbox {
} }
.alert-box .icon { .alert-box .icon {
display: block; display: block;
vertical-align: middle;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.alert-box .icon .material-icons { .alert-box .icon .material-icons {
@@ -691,7 +693,6 @@ table.permission-editor td.checkbox {
height: 1.5rem; height: 1.5rem;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1.5rem; line-height: 1.5rem;
vertical-align: middle;
color: #0097A7; color: #0097A7;
position: absolute; position: absolute;
right: 0.05rem; right: 0.05rem;
@@ -943,6 +944,49 @@ table.permission-editor td.checkbox {
vertical-align: middle; 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 */ /* Talk bubbles */
.chat-box { .chat-box {
list-style: none; list-style: none;
@@ -1078,7 +1122,7 @@ table.permission-editor td.checkbox {
.chat-box .timestamp { .chat-box .timestamp {
display: inline-block; display: inline-block;
position:relative; position: relative;
font-size: 80%; font-size: 80%;
color: #777; color: #777;
padding-left: 1rem; padding-left: 1rem;
@@ -1107,13 +1151,20 @@ table.permission-editor td.checkbox {
color:#777; 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 styles */
.attachment { .attachment {
display: flex; display: flex;
} }
.attachment .material-icons {
vertical-align: middle;
}
.attachment:last-of-type { .attachment:last-of-type {
margin-bottom: -1rem; margin-bottom: -1rem;
} }
@@ -1162,12 +1213,13 @@ table.permission-editor td.checkbox {
.dialog-overlay { .dialog-overlay {
position: absolute; position: absolute;
display: flex; display: flex;
flex-direction: column;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 10; z-index: 10;
background-color: rgba(0,0,0,0.25); background-color: rgba(255,255,255,0.90);
} }
.modal-dialog { .modal-dialog {
display: flex; display: flex;
@@ -1449,6 +1501,7 @@ ul.tabbar li.active > a:after {
.material-icons { .material-icons {
font-size: 1.4rem; font-size: 1.4rem;
line-height: 1; line-height: 1;
vertical-align: middle;
} }
.material-icons.small { .material-icons.small {
font-size: 1rem; font-size: 1rem;
+15 -5
View File
@@ -89,7 +89,7 @@ Sample JSON representation of the text above:
LN: { name: 'a', isVoid: false }, LN: { name: 'a', isVoid: false },
MN: { name: 'a', isVoid: false }, MN: { name: 'a', isVoid: false },
HT: { 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. // Convert base64-encoded string into Blob.
@@ -141,12 +141,18 @@ Sample JSON representation of the text above:
return (data.name ? '</a>' : ''); return (data.name ? '</a>' : '');
}, },
props: function(data) { props: function(data) {
return { var url = base64toObjectUrl(data.val, data.mime);
src: base64toObjectUrl(data.val, data.mime), return [
{
download: data.name,
href: url
},
{
src: url,
width: data.width, width: data.width,
height: data.height, height: data.height,
download: data.name }
}; ];
}, },
} }
}; };
@@ -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. // Value is base64 encoded. The actual object size is smaller than the encoded length.
return entity.val ? (entity.val.length * 0.75) | 0 : 0; 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 * Get HTML tag for a given two-letter style name
* @param {string} style - two-letter style, like ST or LN * @param {string} style - two-letter style, like ST or LN
+150 -42
View File
@@ -57,6 +57,10 @@
// greater value in production. Also increase max_message_size in server config. // greater value in production. Also increase max_message_size in server config.
var MAX_ATTACHMENT_SIZE = 1 << 17; 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. // Short representation of time in the past.
function shortDateFormat(then) { function shortDateFormat(then) {
var locale = window.navigator.userLanguage || window.navigator.language; var locale = window.navigator.userLanguage || window.navigator.language;
@@ -79,6 +83,38 @@
'data:image/' + photo.type + ';base64,' + photo.data : null; '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. // Make shortcut icon appear with a green dot.
function updateFavicon(on) { function updateFavicon(on) {
var oldIcon = document.getElementById("shortcut-icon"); var oldIcon = document.getElementById("shortcut-icon");
@@ -2308,11 +2344,48 @@
}; };
var Message = React.createClass({ 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() { render: function() {
var elementKey = 0; var elementKey = 0;
var formatter = function(style, data, values) { var formatter = function(style, data, values) {
elementKey += 1; elementKey += 1;
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 (
<img className="inline-image" onClick={this.handlePreviewImage}
data-width={data.width} data-height={data.height}
data-size={Drafty.getAttachmentSize(data)}
data-mime={Drafty.getAttachmentType(data)}
src={url} style={style} alt="" title={data.name} />
);
} else {
var el = Drafty.tagName(style); var el = Drafty.tagName(style);
if (el) { if (el) {
var attr = Drafty.attrValue(style, data) || {}; var attr = Drafty.attrValue(style, data) || {};
@@ -2321,6 +2394,7 @@
} else { } else {
return values; return values;
} }
}
}; };
var sideClass = this.props.sequence + " " + (this.props.response ? "left" : "right"); var sideClass = this.props.sequence + " " + (this.props.response ? "left" : "right");
@@ -2338,7 +2412,7 @@
filename={att.name} blobUrl={Drafty.getBlobUrl(att)} filename={att.name} blobUrl={Drafty.getBlobUrl(att)}
size={bytesToHumanSize(Drafty.getAttachmentSize(att))} key={i} />); size={bytesToHumanSize(Drafty.getAttachmentSize(att))} key={i} />);
}, this); }, this);
content = React.createElement('span', null, Drafty.format(content, formatter)); content = React.createElement('span', null, Drafty.format(content, formatter, this));
} }
return ( return (
@@ -2402,7 +2476,7 @@
<div className="attachment"> <div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div> <div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column"> <div className="flex-column">
<div>{filename} <span className="small">({this.props.size})</span></div> <div>{filename} <span className="small gray">({this.props.size})</span></div>
<div><a href={this.props.blobUrl} download={this.props.filename}> <div><a href={this.props.blobUrl} download={this.props.filename}>
<i className="material-icons">file_download</i> save <i className="material-icons">file_download</i> save
</a></div> </a></div>
@@ -2422,7 +2496,8 @@
scrollPosition: 0, scrollPosition: 0,
readOnly: false, readOnly: false,
writeOnly: false, writeOnly: false,
typingIndicator: false typingIndicator: false,
imagePreview: null
}; };
}, },
@@ -2483,6 +2558,7 @@
this.setState({ this.setState({
messages: msgs, messages: msgs,
topic: props.topic, topic: props.topic,
imagePreview: null,
scrollPosition: 0 scrollPosition: 0
}); });
tryToResubscribe = true; tryToResubscribe = true;
@@ -2618,6 +2694,14 @@
} }
}, },
handleImagePreview: function(content) {
this.setState({ imagePreview: content });
},
handleCloseImagePreview: function() {
this.setState({ imagePreview: null });
},
handleContextClick: function(e) { handleContextClick: function(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -2678,7 +2762,8 @@
<Message content={msg.content} mimeType={msg.head ? msg.head.mime : null} <Message content={msg.content} mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} response={isReply} timestamp={msg.ts} response={isReply}
userFrom={userFrom} userName={userName} userAvatar={userAvatar} userFrom={userFrom} userName={userName} userAvatar={userAvatar}
sequence={sequence} received={msgReceived} key={msg.seq} /> sequence={sequence} received={msgReceived}
onImagePreview={this.handleImagePreview} key={msg.seq} />
); );
} }
@@ -2700,7 +2785,7 @@
<div id="topic-caption-panel" className="caption-panel"> <div id="topic-caption-panel" className="caption-panel">
{this.props.displayMobile ? {this.props.displayMobile ?
<a href="#" id="hide-message-view" onClick={this.handleBackNavigation}> <a href="#" id="hide-message-view" onClick={this.handleBackNavigation}>
<i className="material-icons big">arrow_back</i> <i className="material-icons">arrow_back</i>
</a> </a>
: :
null} null}
@@ -2716,7 +2801,7 @@
<div id="topic-users"></div> <div id="topic-users"></div>
<div> <div>
<a href="#" onClick={this.handleContextClick}> <a href="#" onClick={this.handleContextClick}>
<i className="material-icons big">more_vert</i> <i className="material-icons">more_vert</i>
</a> </a>
</div> </div>
</div> </div>
@@ -2740,6 +2825,9 @@
disabled={this.state.readOnly} disabled={this.state.readOnly}
sendMessage={this.props.sendMessage} sendMessage={this.props.sendMessage}
onError={this.props.onError} /> onError={this.props.onError} />
{this.state.imagePreview ?
<MessagesView.ImagePreview content={this.state.imagePreview}
onClose={this.handleCloseImagePreview} /> : null}
</div> </div>
); );
} else { } else {
@@ -2766,11 +2854,18 @@
handleAttachImage: function(e) { handleAttachImage: function(e) {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
var file = e.target.files[0]; var file = e.target.files[0];
if (file.size > MAX_ATTACHMENT_SIZE) { var instance = this;
this.props.onError("The image size " + bytesToHumanSize(file.size) + 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"); " exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " limit.", "err");
return; 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 reader = new FileReader();
var instance = this; 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)); instance.props.sendMessage(Drafty.attachFile(null, file.type, reader.result.split(',')[1], file.name));
}, false); }, false);
reader.readAsDataURL(file); 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 */ MessagesView.ImagePreview = React.createClass({
var ModalDialogWrapper = React.createClass({ getInitialState: function() {
render: function() { return {};
var dialog = null; },
switch (this.props.dialog) {
case "new-topic": componentDidMount: function() {
dialog = <ModalDialogWrapper.NewTopic this.setState({
onOk={this.props.onOk} width: this.container.clientWidth,
onCancel={this.props.onCancel} />; height: this.container.clientHeight - REM_SIZE * 7
break;
default:
break;
}
if (dialog) {
dialog = <div className="dialog-overlay">{dialog}</div>;
}
return dialog;
}
}); });
},
ModalDialogWrapper.NewTopic = React.createClass({
render: function() { 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 ( return (
<div className="modal-dialog"> <div id="image-preview" ref={function(ref) {instance.container = ref; }}
<div className="title-panel"> onClick={this.props.onClose}>
New topic <div id="image-preview-caption-panel">
<a href="#" download={this.props.content.filename}>
<i className="material-icons">file_download</i> download
</a>
<a href="#" onClick={this.props.onClose}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container">
<img src={this.props.content.url} width={width} height={height} />
</div>
<div id="image-preview-footer">
<div><div><b>File name</b>:</div><div><span title={this.props.content.filename}>{filename}</span></div></div>
<div><div><b>Content type</b>:</div><div>{this.props.content.type}</div></div>
<div>
<div><b>Size</b>:</div>
<div>{this.props.content.width} &times; {this.props.content.height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div> </div>
<div className="body-panel">Not implemented</div>
<div className="dialog-buttons">
<a href="#" onClick={this.props.onOk}>Yes</a>
<a href="#" onClick={this.props.onCancel}>OK</a>
</div> </div>
</div> </div>
); );
} }
}); });
/* End of modal dialog wrapper */ /* END Conversation panel */
/* The top-level class to hold all fuinctionality together */ /* The top-level class to hold all fuinctionality together */
var AppView = React.createClass({ var AppView = React.createClass({
@@ -3385,11 +3498,6 @@
render: function() { render: function() {
return ( return (
<div id="app-container"> <div id="app-container">
<ModalDialogWrapper
dialog={this.state.dialogSelected}
onOk={this.handleDialogCancel}
onCancel={this.handleDialogCancel} />
<ContextMenu <ContextMenu
bounds={this.state.contextMenuBounds} bounds={this.state.contextMenuBounds}
clickAt={this.state.contextMenuClickAt} clickAt={this.state.contextMenuClickAt}